Prechádzať zdrojové kódy

UI: storage quota in sidebar, fixed sidebar overflow, compare mode improvements

- Sidebar: add storage quota bar (used/quota bytes + progress bar)
  in the user/logout footer section
- Sidebar: add h-screen + overflow-hidden to aside so it never
  stretches the page even with many nav items
- Compare mode: Annot. toggle button above each video to show/hide
  annotations and speech bubbles per video independently
- Compare mode: comment panels now fill available height (flex-1 min-h-0)
  instead of fixed max-h-32 — scroll to see all comments
- Stroke: lineWidth 3→6, arrow headLen 16→32 for better visibility
- User interface: add storageQuota/storageUsed fields to User type
Claude Dev 1 mesiac pred
rodič
commit
24ba1ec838

+ 31 - 3
src/app/(dashboard)/layout.tsx

@@ -6,6 +6,14 @@ import Link from 'next/link';
 import { useAuth } from '@/lib/auth-context';
 import { Avatar } from '@/components/ui/avatar';
 
+function formatBytes(bytes: number): string {
+  if (bytes === 0) return '0 B';
+  const k = 1024;
+  const sizes = ['B', 'KB', 'MB', 'GB'];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
 export default function DashboardLayout({ children }: { children: React.ReactNode }) {
   const { user, loading, logout } = useAuth();
   const router = useRouter();
@@ -119,8 +127,28 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
       </nav>
 
       {/* User / logout */}
-      <div className="py-3 px-3"
+      <div className="py-3 px-3 shrink-0"
            style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
+        {/* Storage quota bar */}
+        {(user.storageQuota ?? 0) > 0 && (
+          <div className="mb-2 px-1">
+            <div className="flex items-center justify-between mb-1">
+              <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>Storage</span>
+              <span className="text-[10px] font-mono" style={{ color: 'var(--text-subtle)' }}>
+                {formatBytes(user.storageUsed ?? 0)} / {formatBytes(user.storageQuota ?? 0)}
+              </span>
+            </div>
+            <div className="h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
+              <div
+                className="h-full rounded-full transition-all"
+                style={{
+                  width: `${Math.min(100, ((user.storageUsed ?? 0) / (user.storageQuota ?? 1)) * 100)}%`,
+                  background: ((user.storageUsed ?? 0) / (user.storageQuota ?? 1)) > 0.85 ? '#EF4444' : '#6366F1',
+                }}
+              />
+            </div>
+          </div>
+        )}
         <div className="flex items-center gap-2.5 p-2 rounded-lg"
              style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
           <Avatar name={user.name} size="md" />
@@ -179,8 +207,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
       {/* ── Sidebar (drawer on mobile, fixed sidebar on desktop) ─ */}
       <aside
         className={`
-          fixed md:relative inset-y-0 left-0 z-50 md:z-auto
-          flex flex-col shrink-0
+          fixed inset-y-0 left-0 z-50 md:z-auto md:h-screen
+          flex flex-col shrink-0 overflow-hidden
           transition-transform duration-200 ease-out md:transition-none
           w-56 md:w-56
           ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}

+ 129 - 90
src/app/review/[assetId]/page.tsx

@@ -66,6 +66,9 @@ export default function ReviewPage() {
   const [compareMismatch, setCompareMismatch] = useState<string | null>(null);
   const [compareComments, setCompareComments] = useState<Comment[]>([]);
   const [playing, setPlaying] = useState(false);
+  // Toggle annotation + speech bubble visibility per video in compare mode
+  const [showMainAnnotations, setShowMainAnnotations] = useState(true);
+  const [showCompareAnnotations, setShowCompareAnnotations] = useState(true);
 
   const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
     setShowComparePicker(false);
@@ -590,113 +593,69 @@ export default function ReviewPage() {
 
           {/* ── Side-by-side compare layout ───────────────────────── */}
           {compareMode ? (
-            <div className="flex gap-2 w-full">
+            <div className="flex gap-2 w-full flex-1 min-h-0">
               {/* Main video + its comments */}
-              <div className="flex-1 min-w-0 flex flex-col gap-0">
+              <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
+                {/* Annotation toggle */}
+                <div className="flex items-center gap-2 mb-1 px-1">
+                  <button
+                    onClick={() => setShowMainAnnotations(v => !v)}
+                    className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
+                    style={showMainAnnotations
+                      ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
+                      : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
+                    title={showMainAnnotations ? 'Hide annotations' : 'Show annotations'}
+                  >
+                    <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                      <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
+                    </svg>
+                    Annot.
+                  </button>
+                </div>
                 <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
                   {asset.title}
                 </div>
-                <VideoPlayer
-                  src={videoUrl}
-                  mimeType={asset.mimeType}
-                  fps={fps}
-                  comments={allComments}
-                  visibleAnnotations={visibleAnnotations}
-                  drawMode={drawMode}
-                  drawTool={drawTool}
-                  drawColor={drawColor}
-                  onDrawModeChange={setDrawMode}
-                  onDrawToolChange={setDrawTool}
-                  onDrawColorChange={setDrawColor}
-                  pendingStrokes={pendingStrokes}
-                  onStrokeComplete={handleStrokeComplete}
-                  onTimeUpdate={handleTimeUpdate}
-                  onCommentClick={handleCommentSeek}
-                  onPlayingChange={setPlaying}
-                  onTimelineSeek={handleTimeUpdate}
-                  externalCurrentTime={currentTime}
-                  externalPlaying={playing}
-                />
-                {/* Comments below main video */}
-                <div className="mt-2 rounded-xl overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
-                  <div className="px-3 py-2 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
-                    <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
-                      Comments
-                    </span>
-                    <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
-                      {visibleComments.length}
-                    </span>
-                    <span className="font-mono text-[11px] ml-auto" style={{ color: '#818CF8' }}>
-                      {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
-                    </span>
-                  </div>
-                  <div className="max-h-32 overflow-y-auto">
-                    {visibleComments.length === 0 ? (
-                      <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
-                    ) : (
-                      visibleComments.slice(0, 5).map(comment => (
-                        <div key={comment.id} className="px-3 py-2 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
-                          <Avatar name={comment.user?.name ?? 'U'} size="xs" />
-                          <div className="flex-1 min-w-0">
-                            <div className="flex items-center gap-1.5 mb-0.5">
-                              <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
-                              {comment.timestamp != null && (
-                                <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
-                                  {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
-                                </span>
-                              )}
-                            </div>
-                            <p className="text-[11px] truncate" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
-                          </div>
-                        </div>
-                      ))
-                    )}
-                  </div>
-                </div>
-              </div>
-
-              {/* Compare video + its comments — only show when durations match */}
-              {compareAsset && !compareMismatch && (
-                <div className="flex-1 min-w-0 flex flex-col gap-0">
-                  <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
-                    {compareAsset.title}
-                  </div>
+                <div className="flex-1 min-h-0 flex flex-col gap-0">
                   <VideoPlayer
-                    src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
-                    mimeType={compareAsset.mimeType}
-                    fps={compareAsset.fps ?? 30}
-                    comments={compareComments}
-                    visibleAnnotations={compareVisibleAnnotations}
-                    drawMode={false}
+                    src={videoUrl}
+                    mimeType={asset.mimeType}
+                    fps={fps}
+                    comments={showMainAnnotations ? allComments : []}
+                    visibleAnnotations={showMainAnnotations ? visibleAnnotations : []}
+                    drawMode={drawMode}
                     drawTool={drawTool}
                     drawColor={drawColor}
-                    onDrawModeChange={() => {}}
-                    onDrawToolChange={() => {}}
-                    onDrawColorChange={() => {}}
-                    pendingStrokes={[]}
-                    onStrokeComplete={() => {}}
-                    onTimeUpdate={() => {}}
-                    onCommentClick={() => {}}
-                    isComparePlayer={true}
+                    onDrawModeChange={setDrawMode}
+                    onDrawToolChange={setDrawTool}
+                    onDrawColorChange={setDrawColor}
+                    pendingStrokes={pendingStrokes}
+                    onStrokeComplete={handleStrokeComplete}
+                    onTimeUpdate={handleTimeUpdate}
+                    onCommentClick={handleCommentSeek}
+                    onPlayingChange={setPlaying}
+                    onTimelineSeek={handleTimeUpdate}
                     externalCurrentTime={currentTime}
                     externalPlaying={playing}
                   />
-                  {/* Comments below compare video */}
-                  <div className="mt-2 rounded-xl overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
-                    <div className="px-3 py-2 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+                  {/* 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)' }}>
+                    <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
                       <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
                         Comments
                       </span>
                       <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
-                        {compareVisibleComments.length}
+                        {visibleComments.length}
+                      </span>
+                      <span className="font-mono text-[11px] ml-auto" style={{ color: '#818CF8' }}>
+                        {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
                       </span>
                     </div>
-                    <div className="max-h-32 overflow-y-auto">
-                      {compareVisibleComments.length === 0 ? (
+                    <div className="flex-1 overflow-y-auto scroll-area">
+                      {visibleComments.length === 0 ? (
                         <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
                       ) : (
-                        compareVisibleComments.slice(0, 5).map(comment => (
-                          <div key={comment.id} className="px-3 py-2 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
+                        visibleComments.map(comment => (
+                          <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
                             <Avatar name={comment.user?.name ?? 'U'} size="xs" />
                             <div className="flex-1 min-w-0">
                               <div className="flex items-center gap-1.5 mb-0.5">
@@ -707,7 +666,7 @@ export default function ReviewPage() {
                                   </span>
                                 )}
                               </div>
-                              <p className="text-[11px] truncate" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
+                              <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
                             </div>
                           </div>
                         ))
@@ -715,6 +674,86 @@ export default function ReviewPage() {
                     </div>
                   </div>
                 </div>
+              </div>
+
+              {/* Compare video + its comments — only show when durations match */}
+              {compareAsset && !compareMismatch && (
+                <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
+                  {/* Annotation toggle */}
+                  <div className="flex items-center gap-2 mb-1 px-1">
+                    <button
+                      onClick={() => setShowCompareAnnotations(v => !v)}
+                      className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
+                      style={showCompareAnnotations
+                        ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
+                        : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
+                      title={showCompareAnnotations ? 'Hide annotations' : 'Show annotations'}
+                    >
+                      <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                        <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
+                      </svg>
+                      Annot.
+                    </button>
+                  </div>
+                  <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
+                    {compareAsset.title}
+                  </div>
+                  <div className="flex-1 min-h-0 flex flex-col gap-0">
+                    <VideoPlayer
+                      src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
+                      mimeType={compareAsset.mimeType}
+                      fps={compareAsset.fps ?? 30}
+                      comments={showCompareAnnotations ? compareComments : []}
+                      visibleAnnotations={showCompareAnnotations ? compareVisibleAnnotations : []}
+                      drawMode={false}
+                      drawTool={drawTool}
+                      drawColor={drawColor}
+                      onDrawModeChange={() => {}}
+                      onDrawToolChange={() => {}}
+                      onDrawColorChange={() => {}}
+                      pendingStrokes={[]}
+                      onStrokeComplete={() => {}}
+                      onTimeUpdate={() => {}}
+                      onCommentClick={() => {}}
+                      isComparePlayer={true}
+                      externalCurrentTime={currentTime}
+                      externalPlaying={playing}
+                    />
+                    {/* Comments below compare 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)' }}>
+                      <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+                        <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
+                          Comments
+                        </span>
+                        <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
+                          {compareVisibleComments.length}
+                        </span>
+                      </div>
+                      <div className="flex-1 overflow-y-auto scroll-area">
+                        {compareVisibleComments.length === 0 ? (
+                          <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
+                        ) : (
+                          compareVisibleComments.map(comment => (
+                            <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
+                              <Avatar name={comment.user?.name ?? 'U'} size="xs" />
+                              <div className="flex-1 min-w-0">
+                                <div className="flex items-center gap-1.5 mb-0.5">
+                                  <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
+                                  {comment.timestamp != null && (
+                                    <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
+                                      {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
+                                    </span>
+                                  )}
+                                </div>
+                                <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
+                              </div>
+                            </div>
+                          ))
+                        )}
+                      </div>
+                    </div>
+                  </div>
+                </div>
               )}
             </div>
           ) : (

+ 2 - 2
src/components/video-player/AnnotationCanvas.tsx

@@ -20,7 +20,7 @@ export function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
   ctx.save();
   ctx.strokeStyle = ann.color;
   ctx.fillStyle = ann.color;
-  ctx.lineWidth = 4.5; // 1.5× base 3
+  ctx.lineWidth = 6; // 2× base 3
   ctx.lineCap = 'round';
   ctx.lineJoin = 'round';
 
@@ -37,7 +37,7 @@ export function drawShape(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
     const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
     const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
     const angle = Math.atan2(ey - sy, ex - sx);
-    const headLen = 24; // 1.5× original 16
+    const headLen = 32; // 2× original 16
     ctx.beginPath();
     ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
     ctx.beginPath();

+ 2 - 0
src/lib/api.ts

@@ -308,6 +308,8 @@ export interface User {
   name: string;
   globalRole: string;
   avatarUrl?: string | null;
+  storageQuota?: number; // bytes
+  storageUsed?: number;   // bytes
 }
 
 export interface AdminUser extends User {