ソースを参照

fix: sidebar overlap, storage quota API, frame icons, prev/next comment buttons

Backend:
- auth: include storageQuota + storageUsed in login and /me responses

UI:
- Sidebar: add md:ml-56 to main so fixed sidebar doesn't overlap content
- Compare mode: use mainVideoRef for seek so timestamp clicks always work
- Frame step icons: clear left/right bracket arrows instead of confusing skip icons
- Controls: add prev-comment (⏮ double-chevron) and next-comment (⏭ double-chevron)
  buttons flanking the play controls
Claude Dev 1 ヶ月 前
コミット
c00960a96a

+ 24 - 3
packages/api/src/routes/auth.ts

@@ -67,8 +67,10 @@ router.post('/register', async (req: Request, res: Response) => {
         name,
         password: hashed,
         globalRole,
+        storageQuota: 524288000, // 500 MB default
+        storageUsed: 0,
       },
-      select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true },
+      select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, storageQuota: true, storageUsed: true },
     });
 
     const token = jwt.sign(
@@ -208,6 +210,13 @@ router.post('/login', async (req: Request, res: Response) => {
       maxAge: 7 * 24 * 60 * 60 * 1000,
     });
 
+    // Compute storageUsed from owned projects
+    const ownedAssets = await prisma.asset.findMany({
+      where: { project: { ownerId: user.id } },
+      select: { fileSize: true },
+    });
+    const storageUsed = ownedAssets.reduce((s, a) => s + a.fileSize, 0);
+
     res.json({
       user: {
         id: user.id,
@@ -215,6 +224,8 @@ router.post('/login', async (req: Request, res: Response) => {
         name: user.name,
         globalRole: user.globalRole,
         avatarUrl: user.avatarUrl,
+        storageQuota: user.storageQuota,
+        storageUsed,
       },
       token,
       acceptedProjects,
@@ -236,7 +247,10 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
   try {
     const user = await prisma.user.findUnique({
       where: { id: req.user!.userId },
-      select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true },
+      select: {
+        id: true, email: true, name: true, globalRole: true, avatarUrl: true,
+        storageQuota: true,
+      },
     });
 
     if (!user) {
@@ -244,7 +258,14 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
       return;
     }
 
-    res.json({ user });
+    // Compute storageUsed from owned projects
+    const ownedAssets = await prisma.asset.findMany({
+      where: { project: { ownerId: req.user!.userId } },
+      select: { fileSize: true },
+    });
+    const storageUsed = ownedAssets.reduce((s, a) => s + a.fileSize, 0);
+
+    res.json({ user: { ...user, storageUsed } });
   } catch (err) {
     console.error('Me error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 2 - 2
src/app/(dashboard)/layout.tsx

@@ -234,8 +234,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
         <SidebarContent />
       </aside>
 
-      {/* ── Main content (padding-top on mobile for hamburger) ─── */}
-      <main className="flex-1 overflow-auto min-w-0 pt-12 md:pt-0" style={{ background: 'var(--bg)' }}>
+      {/* ── Main content (padding-left on desktop for fixed sidebar, padding-top on mobile for hamburger) ─── */}
+      <main className="flex-1 overflow-auto min-w-0 md:ml-56 pt-12 md:pt-0" style={{ background: 'var(--bg)' }}>
         {children}
       </main>
     </div>

+ 30 - 4
src/app/review/[assetId]/page.tsx

@@ -69,6 +69,8 @@ export default function ReviewPage() {
   // Toggle annotation + speech bubble visibility per video in compare mode
   const [showMainAnnotations, setShowMainAnnotations] = useState(true);
   const [showCompareAnnotations, setShowCompareAnnotations] = useState(true);
+  // Video element ref so we can seek directly from comment timestamp clicks
+  const mainVideoRef = useRef<HTMLVideoElement>(null);
 
   const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
     setShowComparePicker(false);
@@ -354,10 +356,9 @@ export default function ReviewPage() {
   const handleCommentSeek = useCallback((comment: Comment) => {
     const time = comment.timestamp ?? 0;
     setCurrentTime(time);
-    const videoEl = document.querySelector('video') as HTMLVideoElement | null;
-    if (videoEl) {
-      videoEl.pause();
-      videoEl.currentTime = time;
+    if (mainVideoRef.current) {
+      mainVideoRef.current.pause();
+      mainVideoRef.current.currentTime = time;
     }
   }, []);
 
@@ -375,6 +376,25 @@ export default function ReviewPage() {
   const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
   const visibleComments = comments.filter(c => !c.deleted && (showResolved || !c.resolved));
 
+  // Seek to previous/next comment (defined here so they can reference visibleComments)
+  const handlePrevComment = useCallback(() => {
+    const ts = visibleComments
+      .filter(c => c.timestamp != null)
+      .map(c => c.timestamp as number)
+      .sort((a, b) => b - a);
+    const prev = ts.find(t => t < currentTime - 0.3);
+    if (prev !== undefined) handleCommentSeek({ timestamp: prev } as Comment);
+  }, [visibleComments, currentTime, handleCommentSeek]);
+
+  const handleNextComment = useCallback(() => {
+    const ts = visibleComments
+      .filter(c => c.timestamp != null)
+      .map(c => c.timestamp as number)
+      .sort((a, b) => a - b);
+    const next = ts.find(t => t > currentTime + 0.3);
+    if (next !== undefined) handleCommentSeek({ timestamp: next } as Comment);
+  }, [visibleComments, currentTime, handleCommentSeek]);
+
   // Only main comments (not replies, not deleted) have annotations that should show on the video
   const visibleAnnotations = visibleComments
     .filter(c => !c.deleted)
@@ -636,6 +656,9 @@ export default function ReviewPage() {
                     onTimelineSeek={handleTimeUpdate}
                     externalCurrentTime={currentTime}
                     externalPlaying={playing}
+                    videoRef={mainVideoRef}
+                    onPrevComment={handlePrevComment}
+                    onNextComment={handleNextComment}
                   />
                   {/* Comments below main video — full available height */}
                   <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
@@ -775,6 +798,9 @@ export default function ReviewPage() {
             onTimeUpdate={handleTimeUpdate}
             onCommentClick={handleCommentSeek}
             onPlayingChange={setPlaying}
+            videoRef={mainVideoRef}
+            onPrevComment={handlePrevComment}
+            onNextComment={handleNextComment}
           />
           )}
 

+ 46 - 11
src/components/video-player/VideoPlayer.tsx

@@ -32,6 +32,12 @@ interface Props {
   onPlayingChange?: (playing: boolean) => void;
   /** When provided, the parent intercepts timeline seeks in compare mode */
   onTimelineSeek?: (time: number) => void;
+  /** Pass-through ref to the underlying <video> element so parent can seek directly */
+  videoRef?: React.RefObject<HTMLVideoElement | null>;
+  /** Called when user clicks the previous-comment button */
+  onPrevComment?: () => void;
+  /** Called when user clicks the next-comment button */
+  onNextComment?: () => void;
   // ── Compare mode ───────────────────────────────────────────────────────
   /** When provided, this player is slave to the main player in compare mode */
   isComparePlayer?: boolean;
@@ -62,8 +68,13 @@ export function VideoPlayer({
   isComparePlayer = false,
   externalCurrentTime,
   externalPlaying,
+  videoRef: externalVideoRef,
+  onPrevComment,
+  onNextComment,
 }: Props) {
-  const videoRef = useRef<HTMLVideoElement>(null);
+  const internalVideoRef = useRef<HTMLVideoElement>(null);
+  // Use external ref if provided, otherwise internal
+  const videoRef = externalVideoRef ?? internalVideoRef;
   const containerRef = useRef<HTMLDivElement>(null);
   const displayCanvasRef = useRef<HTMLCanvasElement>(null);
   const videoCallbackRef = useRef<number | null>(null);
@@ -499,16 +510,28 @@ export function VideoPlayer({
 
         <div className="flex-1" />
 
-        {/* 3 playback buttons — centered, touch-friendly */}
-        <div className="flex items-center gap-2">
+        {/* 5 playback buttons — prev-comment, frame-back, play/pause, frame-forward, next-comment */}
+        <div className="flex items-center gap-1">
+          <button
+            onClick={onPrevComment}
+            className="w-9 h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
+            style={{ background: 'rgba(255,255,255,0.08)' }}
+            title="Previous comment"
+          >
+            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M11 19l-7-7 7-7M18 19l-7-7 7-7" />
+            </svg>
+          </button>
+
           <button
             onClick={() => stepFrame(-1)}
-            className="w-10 h-10 md:w-9 md:h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
+            className="w-9 h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
             style={{ background: 'rgba(255,255,255,0.12)' }}
-            title="Previous frame (U)"
+            title="Previous frame ()"
           >
-            <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
-              <path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
+            {/* Frame back — left bracket with vertical bar */}
+            <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
+              <path d="M7 12h1M11 7v10M11 12l-4 5M11 12l-4-5" />
             </svg>
           </button>
 
@@ -531,12 +554,24 @@ export function VideoPlayer({
 
           <button
             onClick={() => stepFrame(1)}
-            className="w-10 h-10 md:w-9 md:h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
+            className="w-9 h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
             style={{ background: 'rgba(255,255,255,0.12)' }}
-            title="Next frame (I)"
+            title="Next frame (→)"
+          >
+            {/* Frame forward — right bracket with vertical bar */}
+            <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
+              <path d="M17 12h-1M13 7v10M13 12l4 5M13 12l4-5" />
+            </svg>
+          </button>
+
+          <button
+            onClick={onNextComment}
+            className="w-9 h-9 flex items-center justify-center rounded-full transition-all active:scale-90"
+            style={{ background: 'rgba(255,255,255,0.08)' }}
+            title="Next comment"
           >
-            <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
-              <path d="M18 18h-2V6h2zm-3.5 0L6 12z" />
+            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M13 5l7 7-7 7M6 5l7 7-7 7" />
             </svg>
           </button>
         </div>