feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useRef, type ReactNode } from 'react';
import type { PageId } from '../../lib/urlState';
export interface NavItem {
id: PageId;
label: string;
}
interface NavDrawerProps {
open: boolean;
onClose: () => void;
visibleNav: NavItem[];
currentPage: PageId;
onNavigate: (page: PageId) => void;
appName: string;
logoUrl: string | null;
returnFocusRef?: React.RefObject<HTMLElement>;
}
const ICON_PROPS = {
width: 22,
height: 22,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.7,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
const NAV_ICONS: Record<PageId, ReactNode> = {
tasks: (
<svg {...ICON_PROPS}>
<line x1="8" y1="6" x2="20" y2="6" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="18" x2="20" y2="18" />
<circle cx="4" cy="6" r="1.4" />
<circle cx="4" cy="12" r="1.4" />
<circle cx="4" cy="18" r="1.4" />
</svg>
),
schedules: (
<svg {...ICON_PROPS}>
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15 14" />
</svg>
),
pieces: (
<svg {...ICON_PROPS}>
<path d="M19 11h-4V7a2 2 0 0 0-4 0H7a2 2 0 0 0-2 2v4h4a2 2 0 0 1 0 4H5v4a2 2 0 0 0 2 2h4v-2a2 2 0 0 1 4 0v2h4a2 2 0 0 0 2-2v-4a2 2 0 0 1 0-4Z" />
</svg>
),
captcha: (
<svg {...ICON_PROPS}>
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6Z" />
<path d="m9 12 2 2 4-4" />
</svg>
),
settings: (
<svg {...ICON_PROPS}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
</svg>
),
users: (
<svg {...ICON_PROPS}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.9" />
<path d="M16 3.1a4 4 0 0 1 0 7.8" />
</svg>
),
help: (
<svg {...ICON_PROPS}>
<circle cx="12" cy="12" r="9" />
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12" y2="17.01" />
</svg>
),
userfolder: (
<svg {...ICON_PROPS}>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
),
};
export function NavDrawer({
open,
onClose,
visibleNav,
currentPage,
onNavigate,
appName,
logoUrl,
returnFocusRef,
}: NavDrawerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const firstItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const timeout = window.setTimeout(() => {
(firstItemRef.current ?? panelRef.current)?.focus();
}, 0);
return () => {
window.clearTimeout(timeout);
returnFocusRef?.current?.focus();
};
}, [open, returnFocusRef]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const prev = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
return () => {
document.documentElement.style.overflow = prev;
};
}, [open]);
const onPanelKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Tab' || !panelRef.current) return;
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
return (
<>
<div
aria-hidden
onClick={onClose}
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
/>
<div
ref={panelRef}
id="nav-drawer"
role="dialog"
aria-modal="true"
aria-label="ナビゲーション"
aria-hidden={!open}
tabIndex={-1}
onKeyDown={onPanelKeyDown}
{...(!open && { inert: '' })}
className={`fixed left-0 top-0 bottom-0 z-50 w-[min(280px,80vw)] bg-white shadow-xl flex flex-col motion-safe:transition-transform duration-200 ease-out ${
open ? 'translate-x-0' : '-translate-x-full'
}`}
style={{
paddingTop: 'env(safe-area-inset-top, 0px)',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}}
>
<div className="flex items-center gap-3 px-4 py-3 border-b border-hairline">
<img
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
alt=""
className="h-6 w-auto max-w-[120px] object-contain"
/>
<span className="flex-1 text-sm font-semibold tracking-tight text-slate-900 truncate">
{appName}
</span>
<span className="text-[10px] font-mono text-slate-400 flex-shrink-0">
v{__APP_VERSION__}
</span>
</div>
<nav className="flex-1 overflow-y-auto py-2" aria-label="メインナビゲーション">
{visibleNav.map((item, idx) => {
const active = currentPage === item.id;
return (
<button
key={item.id}
type="button"
ref={idx === 0 ? firstItemRef : undefined}
onClick={() => {
onNavigate(item.id);
onClose();
}}
aria-current={active ? 'page' : undefined}
className={`w-full h-12 px-4 flex items-center gap-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent-ring ${
active
? 'font-semibold text-accent bg-accent-soft'
: 'text-slate-700 hover:bg-surface'
}`}
>
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
<span className="flex-1 text-left">{item.label}</span>
</button>
);
})}
</nav>
</div>
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
// ui/src/components/layout/ResizeHandle.tsx
import { useEffect, useRef } from 'react';
interface ResizeHandleProps {
/** drag 中に呼ばれる。新しい chatPx を渡す。ref-based で React 再 render しない想定。 */
onResize: (chatPx: number) => void;
/** drag 終了時に 1 度だけ呼ばれる。localStorage 保存用。 */
onResizeEnd: (chatPx: number) => void;
/** ダブルクリックでリセット。 */
onReset: () => void;
railPx: number;
minChatPx: number;
minWorkspacePx: number;
handlePx: number;
}
export function ResizeHandle({
onResize,
onResizeEnd,
onReset,
railPx,
minChatPx,
minWorkspacePx,
handlePx,
}: ResizeHandleProps) {
// latest-ref pattern: callback が render 毎に新しくなっても useEffect の
// listener を付け直さずに済む。これが無いと drag 中に listener が外れる。
const onResizeRef = useRef(onResize);
const onResizeEndRef = useRef(onResizeEnd);
onResizeRef.current = onResize;
onResizeEndRef.current = onResizeEnd;
const draggingRef = useRef(false);
const lastChatPxRef = useRef<number | null>(null);
useEffect(() => {
const handleMove = (e: PointerEvent) => {
if (!draggingRef.current) return;
const grid = document.querySelector<HTMLElement>('[data-focused-grid="1"]');
if (!grid) return;
const rect = grid.getBoundingClientRect();
const maxChatPx = rect.width - railPx - handlePx - minWorkspacePx;
const raw = e.clientX - rect.left - railPx;
const chatPx = Math.max(minChatPx, Math.min(maxChatPx, raw));
lastChatPxRef.current = chatPx;
onResizeRef.current(chatPx);
};
const handleUp = () => {
if (!draggingRef.current) return;
draggingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (lastChatPxRef.current !== null) {
onResizeEndRef.current(lastChatPxRef.current);
}
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleUp);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
};
}, [railPx, handlePx, minChatPx, minWorkspacePx]);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
draggingRef.current = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Chat と Workspace の幅を調整"
onPointerDown={handlePointerDown}
onDoubleClick={onReset}
className="cursor-col-resize bg-transparent hover:bg-slate-300/60 transition-colors flex items-stretch group"
style={{ width: handlePx, touchAction: 'none' }}
>
<div className="w-px bg-hairline mx-auto group-hover:bg-slate-500/40" />
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App';
interface TopBarProps {
currentPage: PageId;
onNavigate: (page: PageId) => void;
isAdmin?: boolean;
authEnabled?: boolean;
user?: AuthUser | null;
appName?: string;
logoUrl?: string | null;
onOpenDrawer: () => void;
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
navDrawerOpen?: boolean;
}
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
{ id: 'pieces', label: 'Pieces', adminOnly: true, requiresAuth: false },
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
{ id: 'help', label: 'ヘルプ', adminOnly: false, requiresAuth: false },
{ id: 'userfolder', label: 'ユーザーフォルダ', adminOnly: false, requiresAuth: false },
];
export function estimateCollapseThreshold(navCount: number): number {
return 430 + navCount * 78 + 60;
}
export function useViewportNarrow(threshold: number): boolean {
const [narrow, setNarrow] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth < threshold : false,
);
useEffect(() => {
if (typeof window === 'undefined') return;
const update = () => setNarrow(window.innerWidth < threshold);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [threshold]);
return narrow;
}
export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
return NAV_ITEMS.filter(item => {
if (item.adminOnly && !isAdmin) return false;
if (item.requiresAuth && !authEnabled) return false;
return true;
});
}
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
const visible = visibleNavItemsFor(isAdmin, authEnabled);
return useViewportNarrow(estimateCollapseThreshold(visible.length));
}
export function TopBar({
currentPage,
onNavigate,
isAdmin = true,
authEnabled = false,
user = null,
appName = 'MAESTRO',
logoUrl = null,
onOpenDrawer,
hamburgerButtonRef,
navDrawerOpen = false,
}: TopBarProps) {
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
return (
<div
className="flex-shrink-0 bg-white border-b border-hairline px-4 flex items-center"
style={{
paddingTop: 'env(safe-area-inset-top, 0px)',
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
}}
>
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5">
<div className="flex items-center gap-4 min-w-0 self-stretch">
{compactMode && (
<button
ref={hamburgerButtonRef}
type="button"
onClick={onOpenDrawer}
aria-label="メニューを開く"
aria-expanded={navDrawerOpen}
aria-haspopup="dialog"
aria-controls="nav-drawer"
className="-ml-2 flex items-center justify-center w-11 h-11 rounded-md text-slate-700 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring transition-colors"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<img
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
alt=""
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
/>
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
{appName}
</span>
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
v{__APP_VERSION__}
</span>
{!compactMode && (
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label="メインナビゲーション">
{visibleNav.map(item => {
const active = currentPage === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => onNavigate(item.id)}
aria-current={active ? 'page' : undefined}
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
active
? 'font-semibold text-slate-900 border-accent'
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
}`}
>
{item.label}
</button>
);
})}
</nav>
)}
</div>
<div className="flex items-center gap-2">
{user && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name ?? user.email}
className="w-6 h-6 rounded-full object-cover"
/>
) : (
<div className="w-6 h-6 rounded-full bg-surface-2 text-slate-700 flex items-center justify-center text-2xs font-semibold uppercase">
{(user.name ?? user.email).charAt(0)}
</div>
)}
<span className="text-xs text-slate-600 hidden md:inline max-w-[120px] truncate">
{user.name ?? user.email}
</span>
</div>
<a
href="/auth/logout"
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
</a>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { useEffect, useRef } from 'react';
interface Props {
/** drag 中に呼ばれる。upperPct (0..100) を渡す。 */
onResize: (upperPct: number) => void;
/** drag 終了時に 1 度だけ呼ばれる。localStorage 保存用。 */
onResizeEnd: (upperPct: number) => void;
/** double-click でリセット。 */
onReset?: () => void;
/** 上下のパネルを含む親要素を識別する data-* selector。 */
parentSelector: string;
minUpperPct?: number;
minLowerPct?: number;
}
export function VerticalResizeHandle({
onResize, onResizeEnd, onReset, parentSelector,
minUpperPct = 20, minLowerPct = 15,
}: Props) {
const onResizeRef = useRef(onResize);
const onResizeEndRef = useRef(onResizeEnd);
onResizeRef.current = onResize;
onResizeEndRef.current = onResizeEnd;
const draggingRef = useRef(false);
const lastPctRef = useRef<number | null>(null);
useEffect(() => {
const handleMove = (e: PointerEvent) => {
if (!draggingRef.current) return;
const parent = document.querySelector<HTMLElement>(parentSelector);
if (!parent) return;
const rect = parent.getBoundingClientRect();
const raw = ((e.clientY - rect.top) / rect.height) * 100;
const clamped = Math.max(minUpperPct, Math.min(100 - minLowerPct, raw));
lastPctRef.current = clamped;
onResizeRef.current(clamped);
};
const handleUp = () => {
if (!draggingRef.current) return;
draggingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (lastPctRef.current !== null) onResizeEndRef.current(lastPctRef.current);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleUp);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
};
}, [parentSelector, minUpperPct, minLowerPct]);
return (
<div
role="separator"
aria-orientation="horizontal"
aria-label="タスクリストと情報パネルの高さを調整"
onPointerDown={(e) => {
e.preventDefault();
draggingRef.current = true;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
}}
onDoubleClick={onReset}
className="cursor-row-resize bg-transparent hover:bg-slate-300/60 transition-colors flex justify-center group"
style={{ height: 6, touchAction: 'none' }}
>
<div className="h-px self-center bg-hairline w-full group-hover:bg-slate-500/40" />
</div>
);
}