The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | import { IconCheck, IconCloudUpload, IconExternalLink, IconFile, IconFileTypePdf, IconPhoto, IconX } from "@tabler/icons-react"; |
| 6 | import type { ColineFilePreview, ColineUploadedFile } from "@colineapp/sdk/v2"; |
| 7 | import { cn } from "../lib/utils"; |
| 8 | import { staggerDelay, transitions } from "../lib/motion"; |
| 9 | import { useColine } from "../provider"; |
| 10 | import { Button } from "./button"; |
| 11 | import { Spinner } from "./spinner"; |
| 12 |
| 13 | function FileTypeIcon({ mimeType }: { mimeType: string | null | undefined }) { |
| 14 | if (mimeType?.startsWith("image/")) return <IconPhoto className="size-4" />; |
| 15 | if (mimeType === "application/pdf") return <IconFileTypePdf className="size-4" />; |
| 16 | return <IconFile className="size-4" />; |
| 17 | } |
| 18 |
| 19 | /** |
| 20 | * Any readable workspace file, previewed: images and video inline, other |
| 21 | * files as a card that opens them in Coline. Uses the viewer's access; |
| 22 | * apps preview each other's files without integrations. |
| 23 | */ |
| 24 | export function FilePreview({ |
| 25 | fileId, |
| 26 | variant = "card", |
| 27 | className, |
| 28 | }: { |
| 29 | fileId: string; |
| 30 | variant?: "inline" | "card" | "full"; |
| 31 | className?: string; |
| 32 | }) { |
| 33 | const coline = useColine(); |
| 34 | // Keyed by file so a new fileId shows the skeleton, not the last file. |
| 35 | const [loaded, setLoaded] = React.useState<{ fileId: string; preview: ColineFilePreview | null; error: string | null } | null>( |
| 36 | null, |
| 37 | ); |
| 38 | React.useEffect(() => { |
| 39 | let cancelled = false; |
| 40 | coline.files |
| 41 | .preview(fileId) |
| 42 | .then((result) => { |
| 43 | if (!cancelled) setLoaded({ fileId, preview: result, error: null }); |
| 44 | }) |
| 45 | .catch((cause: unknown) => { |
| 46 | if (!cancelled) setLoaded({ fileId, preview: null, error: cause instanceof Error ? cause.message : String(cause) }); |
| 47 | }); |
| 48 | return () => { |
| 49 | cancelled = true; |
| 50 | }; |
| 51 | }, [coline, fileId]); |
| 52 | const current = loaded?.fileId === fileId ? loaded : null; |
| 53 | const preview = current?.preview ?? null; |
| 54 | const error = current?.error ?? null; |
| 55 |
| 56 | const open = () => void coline.navigation.openFile(fileId); |
| 57 |
| 58 | if (error) { |
| 59 | return ( |
| 60 | <div className={cn("flex items-center gap-2 rounded-xl border border-dashed border-border px-3 py-2 text-sm text-muted-foreground", className)}> |
| 61 | <IconFile className="size-4" /> Preview unavailable |
| 62 | </div> |
| 63 | ); |
| 64 | } |
| 65 | if (!preview) { |
| 66 | return <div className={cn("animate-pulse rounded-xl bg-muted", variant === "inline" ? "h-8 w-48" : "h-24 w-full", className)} />; |
| 67 | } |
| 68 |
| 69 | const media = preview.media; |
| 70 | if (variant !== "inline" && media?.mimeType?.startsWith("image/")) { |
| 71 | return ( |
| 72 | <motion.button |
| 73 | type="button" |
| 74 | onClick={open} |
| 75 | initial={{ opacity: 0 }} |
| 76 | animate={{ opacity: 1 }} |
| 77 | className={cn("group block overflow-hidden rounded-xl border border-border/60 bg-muted/30 text-left", className)} |
| 78 | > |
| 79 | <img |
| 80 | src={media.url} |
| 81 | alt={preview.name} |
| 82 | loading="lazy" |
| 83 | className={cn( |
| 84 | "w-full object-cover transition-transform duration-300 ease-[var(--ease-out-quint)] group-hover:scale-[1.015]", |
| 85 | variant === "full" ? "max-h-[70vh] object-contain" : "max-h-56", |
| 86 | )} |
| 87 | /> |
| 88 | <span className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"> |
| 89 | <IconPhoto className="size-3.5" /> |
| 90 | <span className="truncate">{preview.name}</span> |
| 91 | </span> |
| 92 | </motion.button> |
| 93 | ); |
| 94 | } |
| 95 | if (variant !== "inline" && media?.mimeType?.startsWith("video/")) { |
| 96 | return ( |
| 97 | <video src={media.url} controls preload="metadata" className={cn("w-full rounded-xl border border-border/60 bg-black", className)} /> |
| 98 | ); |
| 99 | } |
| 100 | return ( |
| 101 | <button |
| 102 | type="button" |
| 103 | onClick={open} |
| 104 | className={cn( |
| 105 | "group flex w-full min-w-0 items-center gap-3 rounded-xl border border-border/60 bg-card text-left transition-[transform,box-shadow] duration-150 hover:-translate-y-px hover:shadow-[0_4px_16px_-6px_rgba(0,0,0,0.15)]", |
| 106 | variant === "inline" ? "w-auto px-2.5 py-1.5" : "px-3 py-2.5", |
| 107 | className, |
| 108 | )} |
| 109 | > |
| 110 | <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> |
| 111 | <FileTypeIcon mimeType={media?.mimeType} /> |
| 112 | </span> |
| 113 | <span className="min-w-0 flex-1"> |
| 114 | <span className="block truncate text-sm font-medium text-foreground">{preview.name}</span> |
| 115 | {variant !== "inline" ? <span className="block truncate text-xs text-muted-foreground">Open in Coline</span> : null} |
| 116 | </span> |
| 117 | <IconExternalLink className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> |
| 118 | </button> |
| 119 | ); |
| 120 | } |
| 121 |
| 122 | interface UploadState { |
| 123 | id: string; |
| 124 | name: string; |
| 125 | status: "uploading" | "done" | "failed"; |
| 126 | error?: string; |
| 127 | file?: ColineUploadedFile; |
| 128 | } |
| 129 |
| 130 | /** |
| 131 | * Drop files (or click to choose) and they upload to Drive through |
| 132 | * Coline, with per-file status. Large files stream directly to storage. |
| 133 | */ |
| 134 | export function FileDropzone({ |
| 135 | onUploaded, |
| 136 | accept, |
| 137 | multiple = true, |
| 138 | driveId, |
| 139 | parentId, |
| 140 | label = "Drop files here", |
| 141 | hint = "or click to choose", |
| 142 | className, |
| 143 | }: { |
| 144 | onUploaded?: (file: ColineUploadedFile) => void; |
| 145 | accept?: string; |
| 146 | multiple?: boolean; |
| 147 | driveId?: string; |
| 148 | parentId?: string | null; |
| 149 | label?: string; |
| 150 | hint?: string; |
| 151 | className?: string; |
| 152 | }) { |
| 153 | const coline = useColine(); |
| 154 | const inputRef = React.useRef<HTMLInputElement | null>(null); |
| 155 | const [dragging, setDragging] = React.useState(false); |
| 156 | const [uploads, setUploads] = React.useState<UploadState[]>([]); |
| 157 | const depth = React.useRef(0); |
| 158 |
| 159 | const upload = (files: File[]) => { |
| 160 | for (const file of files) { |
| 161 | const id = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| 162 | setUploads((current) => [{ id, name: file.name, status: "uploading" as const }, ...current].slice(0, 8)); |
| 163 | void coline.files |
| 164 | .upload({ |
| 165 | name: file.name, |
| 166 | data: file, |
| 167 | ...(file.type ? { mimeType: file.type } : {}), |
| 168 | ...(driveId ? { driveId } : {}), |
| 169 | ...(parentId !== undefined ? { parentId } : {}), |
| 170 | }) |
| 171 | .then((uploaded) => { |
| 172 | setUploads((current) => current.map((entry) => (entry.id === id ? { ...entry, status: "done", file: uploaded } : entry))); |
| 173 | onUploaded?.(uploaded); |
| 174 | }) |
| 175 | .catch((cause: unknown) => { |
| 176 | const message = cause instanceof Error ? cause.message : String(cause); |
| 177 | setUploads((current) => current.map((entry) => (entry.id === id ? { ...entry, status: "failed", error: message } : entry))); |
| 178 | }); |
| 179 | } |
| 180 | }; |
| 181 |
| 182 | return ( |
| 183 | <div className={cn("flex flex-col gap-2", className)}> |
| 184 | <motion.button |
| 185 | type="button" |
| 186 | onClick={() => inputRef.current?.click()} |
| 187 | onDragEnter={(event) => { |
| 188 | event.preventDefault(); |
| 189 | depth.current += 1; |
| 190 | setDragging(true); |
| 191 | }} |
| 192 | onDragOver={(event) => event.preventDefault()} |
| 193 | onDragLeave={() => { |
| 194 | depth.current -= 1; |
| 195 | if (depth.current <= 0) setDragging(false); |
| 196 | }} |
| 197 | onDrop={(event) => { |
| 198 | event.preventDefault(); |
| 199 | depth.current = 0; |
| 200 | setDragging(false); |
| 201 | const files = Array.from(event.dataTransfer.files); |
| 202 | upload(multiple ? files : files.slice(0, 1)); |
| 203 | }} |
| 204 | animate={{ scale: dragging ? 1.01 : 1 }} |
| 205 | transition={transitions.spring} |
| 206 | className={cn( |
| 207 | "flex w-full flex-col items-center justify-center gap-2 rounded-2xl border border-dashed px-6 py-8 text-center outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring/50", |
| 208 | dragging ? "border-action bg-action/[0.06]" : "border-border hover:border-foreground/25 hover:bg-muted/30", |
| 209 | )} |
| 210 | > |
| 211 | <motion.span |
| 212 | animate={{ y: dragging ? -3 : 0 }} |
| 213 | transition={transitions.spring} |
| 214 | className={cn("flex size-10 items-center justify-center rounded-xl", dragging ? "bg-action/12 text-action" : "bg-muted text-muted-foreground")} |
| 215 | > |
| 216 | <IconCloudUpload className="size-5" /> |
| 217 | </motion.span> |
| 218 | <span className="text-sm font-medium text-foreground">{label}</span> |
| 219 | <span className="text-xs text-muted-foreground">{hint}</span> |
| 220 | </motion.button> |
| 221 | <input |
| 222 | ref={inputRef} |
| 223 | type="file" |
| 224 | hidden |
| 225 | multiple={multiple} |
| 226 | accept={accept} |
| 227 | onChange={(event) => { |
| 228 | upload(Array.from(event.target.files ?? [])); |
| 229 | event.target.value = ""; |
| 230 | }} |
| 231 | /> |
| 232 | <ul className="flex flex-col gap-1"> |
| 233 | <AnimatePresence initial={false}> |
| 234 | {uploads.map((entry, index) => ( |
| 235 | <motion.li |
| 236 | key={entry.id} |
| 237 | layout |
| 238 | initial={{ opacity: 0, y: -4 }} |
| 239 | animate={{ opacity: 1, y: 0, transition: { ...transitions.enter, delay: staggerDelay(index) } }} |
| 240 | exit={{ opacity: 0 }} |
| 241 | className="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm" |
| 242 | > |
| 243 | <span className="flex size-4 items-center justify-center"> |
| 244 | {entry.status === "uploading" ? ( |
| 245 | <Spinner size="sm" label={`Uploading ${entry.name}`} className="text-muted-foreground" /> |
| 246 | ) : entry.status === "done" ? ( |
| 247 | <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} transition={transitions.spring} className="flex text-positive-fg"> |
| 248 | <IconCheck className="size-4" /> |
| 249 | </motion.span> |
| 250 | ) : ( |
| 251 | <IconX className="size-4 text-risk-fg" /> |
| 252 | )} |
| 253 | </span> |
| 254 | <span className="min-w-0 flex-1 truncate text-foreground">{entry.name}</span> |
| 255 | {entry.status === "failed" ? <span className="truncate text-xs text-risk-fg">{entry.error}</span> : null} |
| 256 | </motion.li> |
| 257 | ))} |
| 258 | </AnimatePresence> |
| 259 | </ul> |
| 260 | </div> |
| 261 | ); |
| 262 | } |
| 263 |
| 264 | /** A file picked from the workspace (host-rendered picker). */ |
| 265 | export function useFilePicker() { |
| 266 | const coline = useColine(); |
| 267 | return React.useCallback((types?: string[]) => coline.pickers.file(types ? { types } : undefined), [coline]); |
| 268 | } |
| 269 |
| 270 | /** Ask where to keep a file, then move it there. Resolves null when dismissed. */ |
| 271 | export function SaveAsButton({ |
| 272 | fileId, |
| 273 | children = "Save to…", |
| 274 | onSaved, |
| 275 | }: { |
| 276 | fileId: string; |
| 277 | children?: React.ReactNode; |
| 278 | onSaved?: (file: { fileId: string; driveId: string; parentId: string | null }) => void; |
| 279 | }) { |
| 280 | const coline = useColine(); |
| 281 | const [pending, setPending] = React.useState(false); |
| 282 | return ( |
| 283 | <Button |
| 284 | variant="outline" |
| 285 | disabled={pending} |
| 286 | onClick={() => { |
| 287 | setPending(true); |
| 288 | void coline.files |
| 289 | .saveAs(fileId) |
| 290 | .then((file) => { |
| 291 | if (file) onSaved?.({ fileId: file.fileId, driveId: file.driveId, parentId: file.parentId }); |
| 292 | }) |
| 293 | .finally(() => setPending(false)); |
| 294 | }} |
| 295 | > |
| 296 | {pending ? <Spinner size="sm" /> : null} |
| 297 | {children} |
| 298 | </Button> |
| 299 | ); |
| 300 | } |
| 301 |