240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { getImageUrl } from "@/lib/api";
|
||
import { useApp } from "@/lib/app-context";
|
||
import { AnnotationCanvas } from "./annotation-canvas";
|
||
import type { Annotation, AnnotationData } from "@/lib/types";
|
||
|
||
interface ImageDetailPanelProps {
|
||
onAnnotationComplete?: (data: AnnotationData) => void;
|
||
}
|
||
|
||
export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps) {
|
||
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
|
||
const [scale, setScale] = useState(1);
|
||
const [showAnnotate, setShowAnnotate] = useState(false);
|
||
|
||
if (!detailImage) return null;
|
||
|
||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||
|
||
const handleDownload = async () => {
|
||
try {
|
||
const fullUrl = getImageUrl(detailImage.url);
|
||
const response = await fetch(fullUrl);
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `epeekit-${Date.now()}.png`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
} catch {
|
||
alert("下载失败");
|
||
}
|
||
};
|
||
|
||
const handleCopyPrompt = () => {
|
||
navigator.clipboard.writeText(detailImage.prompt);
|
||
};
|
||
|
||
const handleAnnotationComplete = (annotations: Annotation[], snapshot: string) => {
|
||
setShowAnnotate(false);
|
||
const data: AnnotationData = {
|
||
imageUrl: detailImage.url,
|
||
annotations,
|
||
snapshot,
|
||
};
|
||
onAnnotationComplete?.(data);
|
||
};
|
||
|
||
if (showAnnotate) {
|
||
return (
|
||
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||
md:w-[480px] flex-shrink-0 border-l border-[var(--border)]
|
||
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
|
||
flex flex-col overflow-hidden">
|
||
<AnnotationCanvas
|
||
imageUrl={getImageUrl(detailImage.url)}
|
||
onComplete={handleAnnotationComplete}
|
||
onCancel={() => setShowAnnotate(false)}
|
||
/>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||
md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
|
||
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
|
||
flex flex-col overflow-hidden">
|
||
{/* 头部 */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||
<span className="text-sm font-semibold text-[var(--text-primary)]">图片详情</span>
|
||
<button
|
||
onClick={() => setDetailImage(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||
cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M18 6L6 18M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
{/* 图片预览 */}
|
||
<div className="p-4">
|
||
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)]">
|
||
<img
|
||
src={getImageUrl(detailImage.url)}
|
||
alt="预览"
|
||
className="w-full transition-transform"
|
||
style={{ transform: `scale(${scale})`, transformOrigin: "center" }}
|
||
/>
|
||
</div>
|
||
|
||
{/* 缩放控制 */}
|
||
<div className="flex items-center justify-center gap-2 mt-3">
|
||
<button
|
||
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
|
||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||
hover:text-[var(--accent)] border border-[var(--border)]
|
||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||
>
|
||
−
|
||
</button>
|
||
<span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center font-medium">
|
||
{Math.round(scale * 100)}%
|
||
</span>
|
||
<button
|
||
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
|
||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||
hover:text-[var(--accent)] border border-[var(--border)]
|
||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => setScale(1)}
|
||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||
hover:text-[var(--accent)] border border-[var(--border)]
|
||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||
>
|
||
重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||
<button
|
||
onClick={handleDownload}
|
||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
|
||
transition-all cursor-pointer"
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||
</svg>
|
||
保存
|
||
</button>
|
||
<button
|
||
onClick={() => toggleFavorite(detailImage.id)}
|
||
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||
border transition-all cursor-pointer ${
|
||
detailImage.favorited
|
||
? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
|
||
: "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||
}`}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill={detailImage.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||
</svg>
|
||
{detailImage.favorited ? "已收藏" : "收藏"}
|
||
</button>
|
||
<button
|
||
onClick={() => setShowAnnotate(true)}
|
||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||
hover:text-[var(--accent)] border border-[var(--border)]
|
||
hover:border-[var(--accent)]/40 transition-all cursor-pointer"
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||
</svg>
|
||
标注
|
||
</button>
|
||
<button
|
||
onClick={() => deleteAssetById(detailImage.id)}
|
||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||
bg-[var(--bg-tertiary)] text-[var(--hot)]/60
|
||
hover:text-[var(--hot)] border border-[var(--border)]
|
||
hover:border-[var(--hot)]/40 transition-all cursor-pointer"
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<polyline points="3 6 5 6 21 6" />
|
||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||
</svg>
|
||
删除
|
||
</button>
|
||
</div>
|
||
|
||
{/* Prompt */}
|
||
{detailImage.prompt && (
|
||
<div className="px-4 pb-3">
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
|
||
<button
|
||
onClick={handleCopyPrompt}
|
||
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||
cursor-pointer transition-colors"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
|
||
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
|
||
{detailImage.prompt}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 标签 */}
|
||
{detailImage.tags.length > 0 && (
|
||
<div className="px-4 pb-3">
|
||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5">标签</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{detailImage.tags.map((tid) => {
|
||
const tag = tagMap.get(tid);
|
||
if (!tag) return null;
|
||
return (
|
||
<span
|
||
key={tid}
|
||
className="text-[10px] px-2 py-0.5 rounded-lg font-medium"
|
||
style={{ backgroundColor: tag.color + "18", color: tag.color }}
|
||
>
|
||
{tag.name}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 元信息 */}
|
||
<div className="px-4 pb-4">
|
||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5">信息</span>
|
||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||
<div>创建时间:{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|