Skip to content
File

Blob: src/client/components/cover-picker.tsx

typescript151 lines
1import { useState, useRef, useCallback } from "react";
2import { Image, X, Upload } from "lucide-react";
3import { uploadFile } from "@/client/lib/uploads";
4import { toast } from "@/client/components/toast-store";
5import { Button } from "@/client/components/ui/button";
6import { Dialog } from "@/client/components/ui/dialog";
7import { UPLOAD_MIME_SET } from "@/shared/constants";
8import { GRADIENT_PRESETS } from "@/shared/page-cover";
9 
10export function CoverPicker({
11 currentCover,
12 onSelect,
13 workspaceId,
14 pageId,
15 disabled = false,
16 title,
17}: {
18 currentCover: string | null;
19 onSelect: (cover: string | null) => void;
20 workspaceId: string;
21 pageId: string;
22 disabled?: boolean;
23 title?: string;
24}) {
25 const [open, setOpen] = useState(false);
26 const [uploading, setUploading] = useState(false);
27 const fileInputRef = useRef<HTMLInputElement>(null);
28 
29 const handleUpload = useCallback(
30 async (file: File) => {
31 if (!UPLOAD_MIME_SET.has(file.type) || !file.type.startsWith("image/")) return;
32 
33 setUploading(true);
34 try {
35 const url = await uploadFile(workspaceId, file, pageId);
36 onSelect(url);
37 setOpen(false);
38 } catch {
39 toast.error("Failed to upload cover");
40 } finally {
41 setUploading(false);
42 }
43 },
44 [workspaceId, pageId, onSelect],
45 );
46 
47 return (
48 <>
49 {currentCover ? (
50 <button
51 onClick={() => setOpen(true)}
52 disabled={disabled}
53 title={title}
54 className="rounded-md bg-zinc-800/80 px-2 py-1 text-sm text-zinc-300 opacity-0 transition hover:bg-zinc-700 hover:text-zinc-100 group-hover/cover:opacity-100 disabled:cursor-not-allowed disabled:opacity-50"
55 >
56 Change cover
57 </button>
58 ) : (
59 <Button
60 variant="subtle"
61 size="xs"
62 onClick={() => setOpen(true)}
63 disabled={disabled}
64 title={title}
65 icon={<Image className="h-4 w-4" />}
66 >
67 Add cover
68 </Button>
69 )}
70
71 <Dialog
72 open={open && !disabled}
73 onClose={() => setOpen(false)}
74 ariaLabelledBy="cover-picker-title"
75 className="w-full max-w-sm p-4"
76 >
77 <div className="mb-3 flex items-center justify-between">
78 <h2 id="cover-picker-title" className="text-base font-medium text-zinc-300">
79 Cover
80 </h2>
81 <div className="flex items-center gap-2">
82 {currentCover && (
83 <Button
84 variant="ghost"
85 size="xs"
86 onClick={() => {
87 onSelect(null);
88 setOpen(false);
89 }}
90 icon={<X className="h-3 w-3" />}
91 >
92 Remove
93 </Button>
94 )}
95 <Button
96 variant="ghost"
97 size="sm"
98 iconOnly
99 onClick={() => setOpen(false)}
100 aria-label="Close"
101 icon={<X className="h-4 w-4" />}
102 />
103 </div>
104 </div>
105
106 <div className="mb-4">
107 <span className="mb-2 block text-xs font-medium uppercase tracking-wider text-zinc-500">Gradients</span>
108 <div className="grid grid-cols-4 gap-2">
109 {GRADIENT_PRESETS.map((gradient) => (
110 <button
111 key={gradient}
112 onClick={() => {
113 onSelect(gradient);
114 setOpen(false);
115 }}
116 className={`h-12 rounded-md transition hover:ring-2 hover:ring-accent-500 ${
117 currentCover === gradient ? "ring-2 ring-accent-500" : ""
118 }`}
119 style={{ background: gradient }}
120 />
121 ))}
122 </div>
123 </div>
124
125 <div>
126 <span className="mb-2 block text-xs font-medium uppercase tracking-wider text-zinc-500">Upload image</span>
127 <button
128 onClick={() => fileInputRef.current?.click()}
129 disabled={uploading}
130 className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-zinc-700 py-4 text-sm text-zinc-400 transition-colors hover:border-zinc-500 hover:text-zinc-300 disabled:opacity-50"
131 >
132 <Upload className="h-4 w-4" />
133 {uploading ? "Uploading..." : "Choose image"}
134 </button>
135 <input
136 ref={fileInputRef}
137 type="file"
138 accept="image/jpeg,image/png,image/gif,image/webp"
139 className="hidden"
140 onChange={(e) => {
141 const file = e.target.files?.[0];
142 if (file) handleUpload(file);
143 e.target.value = "";
144 }}
145 />
146 </div>
147 </Dialog>
148 </>
149 );
150}