소스 검색

fix: comment toggle icons, folder tree icons, counter, avatar consistency + admin deleted comments

UI fixes:
- Comment panel toggle: chevron direction swapped (left=expand, right=collapse)
- FolderTree collapse icon: added explicit rotate-0 for collapsed state
- Comment counter badge: now shows total/visible when resolved are hidden
- Deleted comments toggle for project ADMIN: button shows "N deleted" count,
  toggles show/hide deleted comments in the panel

Avatar consistency (Avatar component with src=avatarUrl everywhere):
- users/page.tsx: replaced custom div with Avatar component
- projects/[projectId]/page.tsx: member list avatar replaced with Avatar
- AssetCard: uploader avatar replaced with Avatar (removed SVG silhouette)

Also: backend allows admin to load deleted comments via ?includeDeleted=true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 개월 전
부모
커밋
3a108fffab

+ 5 - 2
packages/api/src/routes/comments.ts

@@ -24,6 +24,9 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
     const { resolved, includeDeleted } = req.query;
     const isAdmin = req.user!.globalRole === 'ADMIN';
 
+    // includeDeleted requires ADMIN membership in the project
+    const canSeeDeleted = isAdmin;
+
     const asset = await prisma.asset.findFirst({
       where: {
         id: str(req.params.assetId),
@@ -38,8 +41,8 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
 
     const assetId = str(req.params.assetId);
     const where: Record<string, unknown> = { assetId, parentId: null };
-    // Always filter soft-deleted unless explicitly requested
-    if (includeDeleted !== 'true') {
+    // Only filter soft-deleted for non-admin users
+    if (!canSeeDeleted || includeDeleted !== 'true') {
       where.deleted = false;
     }
     if (resolved !== undefined) {

+ 2 - 4
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
 import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
+import { Avatar } from '@/components/ui/avatar';
 import { AssetCard } from '@/components/ui/AssetCard';
 import { FolderTree } from '@/components/folders/FolderTree';
 import { ShareModal } from '@/components/share/ShareModal';
@@ -1078,10 +1079,7 @@ export default function ProjectDetailPage() {
                       <div key={m.id}
                            className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
 
-                        <div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
-                             style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
-                          {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
-                        </div>
+                        <Avatar name={m.user.name} src={m.user.avatarUrl} size="md" />
 
                         <div className="flex-1 min-w-0">
                           <div className="flex items-center gap-2">

+ 2 - 4
src/app/(dashboard)/users/page.tsx

@@ -3,6 +3,7 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useAuth } from '@/lib/auth-context';
 import { usersApi, invitationsApi, AdminUser, AdminInvitation } from '@/lib/api';
+import { Avatar } from '@/components/ui/avatar';
 
 /**
  * Global workspace roles (stored in User.globalRole):
@@ -309,10 +310,7 @@ export default function UsersPage() {
                       {/* Top row: avatar + info + role/actions */}
                       <div className="flex items-start gap-3 p-4">
                         {/* Avatar */}
-                        <div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-semibold shrink-0 mt-0.5"
-                             style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
-                          {u.name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
-                        </div>
+                        <Avatar name={u.name} src={u.avatarUrl} size="md" />
 
                         {/* Info */}
                         <div className="flex-1 min-w-0">

+ 23 - 6
src/app/review/[assetId]/page.tsx

@@ -48,6 +48,7 @@ export default function ReviewPage() {
   const [submitting, setSubmitting] = useState(false);
   const [replyTo, setReplyTo] = useState<Comment | null>(null);
   const [showResolved, setShowResolved] = useState(false);
+  const [showDeleted, setShowDeleted] = useState(false);
   const [showShareModal, setShowShareModal] = useState(false);
 
   // Drawing state — lifted to page level
@@ -160,7 +161,7 @@ export default function ReviewPage() {
     try {
       const [{ asset: a }, { comments: c }] = await Promise.all([
         assetsApi.get(token, assetId),
-        commentsApi.list(token, assetId),
+        commentsApi.list(token, assetId, undefined, true),
       ]);
       setAsset(a);
       setComments(c);
@@ -375,7 +376,10 @@ export default function ReviewPage() {
       : '';
 
   const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
-  const visibleComments = comments.filter(c => !c.deleted && (showResolved || !c.resolved));
+  const visibleComments = comments.filter(c =>
+    (showDeleted || !c.deleted) && (showResolved || !c.resolved)
+  );
+  const deletedCount = comments.filter(c => c.deleted).length;
 
   // Seek to previous/next comment (defined here so they can reference visibleComments)
   const handlePrevComment = useCallback(() => {
@@ -996,6 +1000,9 @@ export default function ReviewPage() {
               <span className="text-xs px-1.5 py-0.5 rounded-full"
                     style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
                 {comments.length}
+                {comments.length !== visibleComments.length && (
+                  <span className="ml-1 opacity-60">/ {visibleComments.length}</span>
+                )}
               </span>
             </div>
             <div className="flex items-center gap-2">
@@ -1009,6 +1016,16 @@ export default function ReviewPage() {
               >
                 {showResolved ? 'Hide resolved' : 'Show resolved'}
               </button>
+              {isProjectAdmin && deletedCount > 0 && (
+                <button
+                  onClick={() => setShowDeleted(v => !v)}
+                  className={`text-[11px] px-2 py-0.5 rounded-md transition-colors ${showDeleted ? 'bg-red-600 text-white' : ''}`}
+                  style={!showDeleted ? { background: 'rgba(239,68,68,0.12)', color: '#FCA5A5' } : {}}
+                  title="Toggle deleted comments"
+                >
+                  {showDeleted ? 'Hide deleted' : `${deletedCount} deleted`}
+                </button>
+              )}
               <button
                 onClick={() => setCommentPanelCollapsed(v => !v)}
                 className="text-[11px] px-2 py-0.5 rounded-md transition-colors"
@@ -1019,11 +1036,11 @@ export default function ReviewPage() {
               >
                 <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                   {commentPanelCollapsed ? (
-                    // Chevron right — panel is collapsed to the right
-                    <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
-                  ) : (
-                    // Chevron left — panel is expanded
+                    // Chevron left — clicking expands the panel (panel slides in from right)
                     <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
+                  ) : (
+                    // Chevron right — clicking collapses the panel (panel slides out to right)
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
                   )}
                 </svg>
               </button>

+ 1 - 1
src/components/folders/FolderTree.tsx

@@ -209,7 +209,7 @@ export function FolderTree({
               className="w-4 h-4 flex items-center justify-center shrink-0 hover:bg-white/10 rounded transition-colors"
             >
               <svg
-                className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
+                className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-90' : 'rotate-0'}`}
                 fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
               >
                 <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />

+ 2 - 3
src/components/ui/AssetCard.tsx

@@ -1,6 +1,7 @@
 'use client';
 
 import { Asset, TranscodeStatus } from '@/lib/api';
+import { Avatar } from '@/components/ui/avatar';
 
 interface Props {
   asset: Asset;
@@ -227,9 +228,7 @@ export function AssetCard({ asset, canManage, showHour, onPlay, onDelete, onCanc
 
         {/* Uploader + date */}
         <div className="flex items-center gap-1.5 mb-2 text-[11px]" style={{ color: 'var(--text-muted)' }}>
-          <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
-            <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
-          </svg>
+          <Avatar name={asset.uploader?.name ?? 'U'} src={asset.uploader?.avatarUrl} size="xs" />
           <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
         </div>
 

+ 5 - 2
src/lib/api.ts

@@ -141,8 +141,11 @@ export const assetsApi = {
 // ── Comments ─────────────────────────────────────────────────────────────────
 
 export const commentsApi = {
-  list: (token: string, assetId: string, resolved?: boolean) => {
-    const q = resolved !== undefined ? `?resolved=${resolved}` : '';
+  list: (token: string, assetId: string, resolved?: boolean, includeDeleted?: boolean) => {
+    const params = new URLSearchParams();
+    if (resolved !== undefined) params.set('resolved', String(resolved));
+    if (includeDeleted) params.set('includeDeleted', 'true');
+    const q = params.toString() ? `?${params.toString()}` : '';
     return apiFetch<{ comments: Comment[] }>(`/api/assets/${assetId}/comments${q}`, { token });
   },