···
3
3
export default function App(props: ParentProps) {
4
4
return (
5
5
<div class="text-text flex min-h-screen flex-col items-center justify-center px-4 font-sans">
6
6
-
<div class="mt-6 w-full max-w-md">{props.children}</div>
6
6
+
<div class="mt-6 w-full max-w-2xl">{props.children}</div>
7
7
<a
8
8
class="text-muted hover:text-accent-hover mt-auto p-4 text-[10px]"
9
9
href="https://github.com/notjuliet/drop"
···
1
1
+
export const fadeIn = { animation: "fade-in 0.4s" };
2
2
+
3
3
+
export const btnClass =
4
4
+
"bg-transparent hover:bg-accent/10 active:scale-95 border-accent text-accent rounded-md border px-4 py-1.5 font-medium transition";
5
5
+
6
6
+
export const btnStyle = { "font-size": "clamp(1rem, 3vw, 1.5rem)" };
···
7
7
"svg",
8
8
"ico",
9
9
"bmp",
10
10
-
"tiff",
11
10
"avif",
12
11
]);
13
12
···
42
41
"env",
43
42
]);
44
43
44
44
+
export const VIDEO_EXTS = new Set(["mp4", "webm", "ogv", "mov"]);
45
45
+
46
46
+
export const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a"]);
47
47
+
48
48
+
export const VIDEO_MIME: Record<string, string> = {
49
49
+
mp4: "video/mp4",
50
50
+
webm: "video/webm",
51
51
+
ogv: "video/ogg",
52
52
+
mov: "video/mp4",
53
53
+
};
54
54
+
55
55
+
export const AUDIO_MIME: Record<string, string> = {
56
56
+
mp3: "audio/mpeg",
57
57
+
wav: "audio/wav",
58
58
+
ogg: "audio/ogg",
59
59
+
flac: "audio/flac",
60
60
+
aac: "audio/aac",
61
61
+
m4a: "audio/mp4",
62
62
+
};
63
63
+
45
64
export const IMAGE_MIME: Record<string, string> = {
46
65
png: "image/png",
47
66
jpg: "image/jpeg",
···
51
70
svg: "image/svg+xml",
52
71
ico: "image/x-icon",
53
72
bmp: "image/bmp",
54
54
-
tiff: "image/tiff",
55
73
avif: "image/avif",
56
74
};
57
75
···
8
8
} from "solid-js";
9
9
10
10
import { generateKey } from "../lib/crypto";
11
11
+
import { btnClass, btnStyle, fadeIn } from "../lib/ui";
11
12
import { formatBytes } from "../lib/utils";
12
13
13
13
-
const viewClass = "flex flex-col items-center gap-2 sm:gap-3";
14
14
-
const fadeIn = { animation: "fade-in 0.4s" };
15
15
-
16
16
-
const btnClass =
17
17
-
"bg-transparent hover:bg-accent/10 active:scale-95 border-accent text-accent rounded-md border px-4 py-1.5 font-medium transition";
18
18
-
const btnStyle = { "font-size": "clamp(1rem, 3vw, 1.5rem)" };
19
14
const ghostClass =
20
20
-
"text-muted hover:text-accent-hover border-none bg-transparent p-0 text-[10px] sm:text-xs";
15
15
+
"text-muted hover:text-accent-hover border-none bg-transparent min-w-16 py-1.5 -my-1.5 text-[10px] sm:text-xs";
21
16
22
17
const DURATION_UNITS: Record<string, number> = {
23
18
s: 1,
···
56
51
const RADIUS = 130;
57
52
const CENTER = RADIUS + 10;
58
53
const SIZE = CENTER * 2;
59
59
-
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
60
54
const WAVE_AMP = 3;
61
55
const WAVE_LEN = RADIUS; // one full wave period
62
56
···
64
58
const topY = CENTER - RADIUS + 2 * RADIUS * (1 - progress() / 100);
65
59
const left = CENTER - RADIUS - WAVE_LEN;
66
60
const right = CENTER + RADIUS + WAVE_LEN;
67
67
-
const bottom = CENTER + RADIUS;
61
61
+
const bottom = CENTER + RADIUS + 2;
68
62
let d = `M ${left} ${bottom} V ${topY}`;
69
63
for (let x = left; x < right; x += WAVE_LEN / 2) {
70
64
const cx = x + WAVE_LEN / 4;
···
76
70
d += ` V ${bottom} Z`;
77
71
return d;
78
72
};
79
79
-
80
80
-
let prevRatio = 0;
81
81
-
const sizeRatio = createMemo(() => {
82
82
-
const f = file();
83
83
-
const max = maxFileSize();
84
84
-
if (!f || !max) return 0;
85
85
-
return Math.min(f.size / max, 1);
86
86
-
});
87
87
-
88
88
-
const animDuration = createMemo(() => {
89
89
-
const cur = sizeRatio();
90
90
-
const dur = Math.max(Math.max(cur, prevRatio) * 800, 200);
91
91
-
prevRatio = cur;
92
92
-
return dur;
93
93
-
});
94
73
95
74
const tooLarge = createMemo(() => {
96
75
const f = file();
···
285
264
</clipPath>
286
265
</defs>
287
266
<style>{`@keyframes wave { from { transform: translateX(0) } to { transform: translateX(-${WAVE_LEN}px) } }`}</style>
288
288
-
<style>{`@keyframes fade-in { 0% { opacity: 0; transform: translateY(4px) } 100% { opacity: 1; transform: translateY(0) } }`}</style>
289
267
<circle
290
268
cx={CENTER}
291
269
cy={CENTER}
292
270
r={RADIUS}
293
271
fill="none"
294
294
-
stroke={dragging() ? "var(--color-accent)" : "var(--color-border)"}
272
272
+
stroke={
273
273
+
tooLarge()
274
274
+
? "var(--color-danger)"
275
275
+
: dragging() || view() === "result"
276
276
+
? "var(--color-accent)"
277
277
+
: "var(--color-border)"
278
278
+
}
295
279
stroke-width="7"
296
280
pathLength={dragging() ? "100" : undefined}
297
281
stroke-dasharray={dragging() ? "3 2" : "none"}
298
282
class="transition-all duration-200"
299
283
/>
300
300
-
<circle
301
301
-
cx={CENTER}
302
302
-
cy={CENTER}
303
303
-
r={RADIUS}
304
304
-
fill="none"
305
305
-
stroke={tooLarge() ? "var(--color-danger)" : "var(--color-accent)"}
306
306
-
stroke-width="7"
307
307
-
stroke-dasharray={`${CIRCUMFERENCE}`}
308
308
-
stroke-dashoffset={`${CIRCUMFERENCE * (1 - (status() !== "idle" || dragging() ? 0 : sizeRatio()))}`}
309
309
-
stroke-linecap="round"
310
310
-
transform={`rotate(-90 ${CENTER} ${CENTER})`}
311
311
-
style={{
312
312
-
transition: dragging()
313
313
-
? "none"
314
314
-
: `all ${animDuration()}ms ease-out`,
315
315
-
}}
316
316
-
/>
317
284
{/* Upload liquid fill */}
318
318
-
<g clip-path="url(#circle-clip)">
319
319
-
<path
320
320
-
d={wavePath()}
321
321
-
fill="var(--color-border)"
322
322
-
style={{
323
323
-
transition: "d 300ms ease-out",
324
324
-
animation:
325
325
-
status() === "uploading" ? `wave 2s linear infinite` : "none",
326
326
-
}}
327
327
-
/>
328
328
-
</g>
285
285
+
<Show when={status() === "uploading"}>
286
286
+
<g clip-path="url(#circle-clip)">
287
287
+
<path
288
288
+
d={wavePath()}
289
289
+
fill="var(--color-border)"
290
290
+
style={{
291
291
+
transition: "d 300ms ease-out",
292
292
+
animation: `wave 2s linear infinite`,
293
293
+
}}
294
294
+
/>
295
295
+
</g>
296
296
+
</Show>
329
297
</svg>
330
298
331
299
<div class="z-10 flex flex-col items-center text-center">
···
334
302
</Show>
335
303
<Show when={!loading()}>
336
304
<Show when={view() === "result"}>
337
337
-
<div class={viewClass} style={fadeIn}>
305
305
+
<div class="flex flex-col items-center gap-3" style={fadeIn}>
338
306
<span
339
307
class="text-muted"
340
308
style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }}
···
358
326
</Show>
359
327
360
328
<Show when={view() === "uploading"}>
361
361
-
<div class={viewClass} style={fadeIn}>
362
362
-
<span
363
363
-
class="text-accent font-medium tabular-nums"
364
364
-
style={{ "font-size": "clamp(1.5rem, 5vw, 2.5rem)" }}
365
365
-
>
366
366
-
{progress()}%
367
367
-
</span>
329
329
+
<div class="flex flex-col items-center gap-3" style={fadeIn}>
330
330
+
<Show when={status() === "uploading"}>
331
331
+
<span
332
332
+
class="text-accent font-medium tabular-nums"
333
333
+
style={{ "font-size": "clamp(1.5rem, 5vw, 2.5rem)" }}
334
334
+
>
335
335
+
{progress()}%
336
336
+
</span>
337
337
+
</Show>
368
338
<span class="text-muted text-[10px] sm:text-xs">
369
339
{status() === "encrypting"
370
340
? "encrypting\u2026"
···
383
353
</Show>
384
354
385
355
<Show when={view() === "file"}>
386
386
-
<div class={viewClass} style={fadeIn}>
356
356
+
<div class="flex flex-col items-center gap-3" style={fadeIn}>
387
357
<span
388
358
class="text-text flex gap-1.5 truncate"
389
359
style={{ "max-width": "clamp(120px, 40vw, 300px)" }}
···
427
397
</Show>
428
398
429
399
<Show when={view() === "empty"}>
430
430
-
<div class={viewClass} style={fadeIn}>
400
400
+
<div class="flex flex-col items-center gap-3" style={fadeIn}>
431
401
<span
432
402
class="text-muted font-medium"
433
403
style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }}
···
1
1
import { useParams } from "@solidjs/router";
2
2
-
import {
3
3
-
createSignal,
4
4
-
Show,
5
5
-
Switch,
6
6
-
Match,
7
7
-
onMount,
8
8
-
onCleanup,
9
9
-
} from "solid-js";
2
2
+
import { createSignal, Show, onMount, onCleanup } from "solid-js";
10
3
11
4
import { importKey } from "../lib/crypto";
5
5
+
import { btnClass, btnStyle, fadeIn } from "../lib/ui";
12
6
import {
13
7
formatBytes,
14
8
formatExpiry,
···
16
10
triggerDownload,
17
11
IMAGE_EXTS,
18
12
TEXT_EXTS,
13
13
+
VIDEO_EXTS,
19
14
IMAGE_MIME,
15
15
+
VIDEO_MIME,
16
16
+
AUDIO_MIME,
20
17
} from "../lib/utils";
21
18
22
22
-
type Stage = "loading" | "meta" | "content" | "error";
23
23
-
type ContentType = "text" | "image" | "binary";
19
19
+
const ghostClass =
20
20
+
"text-muted hover:text-accent hover:border-accent border border-border bg-transparent rounded px-2 py-1 text-sm transition-colors";
21
21
+
22
22
+
type Stage = "loading" | "meta" | "decrypting" | "content" | "error";
23
23
+
type ContentType = "text" | "image" | "video" | "audio" | "binary";
24
24
25
25
export default function View() {
26
26
const params = useParams();
···
31
31
const [expiresAt, setExpiresAt] = createSignal(0);
32
32
const [burnAfterRead, setBurnAfterRead] = createSignal(false);
33
33
const [burned, setBurned] = createSignal(false);
34
34
-
const [loadBtnText, setLoadBtnText] = createSignal("View");
35
35
-
const [loadBtnDisabled, setLoadBtnDisabled] = createSignal(false);
34
34
+
const [progress, setProgress] = createSignal(0);
35
35
+
const [decrypting, setDecrypting] = createSignal(false);
36
36
37
37
const [contentType, setContentType] = createSignal<ContentType>("binary");
38
38
const [textContent, setTextContent] = createSignal("");
39
39
const [imageSrc, setImageSrc] = createSignal("");
40
40
-
const [imageAlt, setImageAlt] = createSignal("");
40
40
+
const [mediaSrc, setMediaSrc] = createSignal("");
41
41
const [fileName, setFileName] = createSignal("");
42
42
+
const [copied, setCopied] = createSignal(false);
42
43
43
44
let decryptedBlob: Blob | null = null;
44
45
const worker = new Worker(
···
47
48
type: "module",
48
49
},
49
50
);
51
51
+
50
52
onCleanup(() => worker.terminate());
51
53
52
54
onMount(async () => {
···
78
80
setSize(info.size);
79
81
setExpiresAt(info.expiresAt);
80
82
setBurnAfterRead(info.burnAfterRead);
81
81
-
setStage("meta");
83
83
+
84
84
+
if (info.burnAfterRead) {
85
85
+
setStage("meta");
86
86
+
} else {
87
87
+
handleView();
88
88
+
}
82
89
});
83
90
84
91
const handleView = async () => {
85
85
-
setLoadBtnDisabled(true);
86
86
-
setLoadBtnText("Decrypting\u2026");
92
92
+
setStage("decrypting");
93
93
+
setProgress(0);
94
94
+
setDecrypting(false);
87
95
88
96
try {
89
89
-
const res = await fetch(`/api/file/${params.id}`);
90
90
-
if (!res.ok) {
91
91
-
setError("File not found or expired.");
92
92
-
setStage("error");
93
93
-
return;
94
94
-
}
97
97
+
const xhr = new XMLHttpRequest();
98
98
+
xhr.open("GET", `/api/file/${params.id}`);
99
99
+
xhr.responseType = "arraybuffer";
100
100
+
101
101
+
const buf = await new Promise<ArrayBuffer>((resolve, reject) => {
102
102
+
xhr.onprogress = (e) => {
103
103
+
if (e.lengthComputable) {
104
104
+
setProgress(Math.round((e.loaded / e.total) * 100));
105
105
+
}
106
106
+
};
107
107
+
xhr.onload = () => {
108
108
+
if (xhr.status >= 200 && xhr.status < 300) {
109
109
+
setProgress(100);
110
110
+
setDecrypting(true);
111
111
+
resolve(xhr.response);
112
112
+
} else {
113
113
+
reject(new Error("File not found or expired."));
114
114
+
}
115
115
+
};
116
116
+
xhr.onerror = () => reject(new Error("Download failed."));
117
117
+
xhr.send();
118
118
+
});
95
119
96
96
-
const buf = await res.arrayBuffer();
97
120
const { fileName: name, fileData } = await new Promise<{
98
121
fileName: string;
99
122
fileData: Uint8Array<ArrayBuffer>;
···
111
134
[buf],
112
135
);
113
136
});
137
137
+
114
138
const ext = getExt(name);
115
139
setFileName(name);
116
140
117
141
if (burnAfterRead()) setBurned(true);
118
142
119
119
-
if (IMAGE_EXTS.has(ext)) {
120
120
-
const mime = IMAGE_MIME[ext] || "image/png";
143
143
+
const mime =
144
144
+
IMAGE_MIME[ext] || VIDEO_MIME[ext] || AUDIO_MIME[ext] || undefined;
145
145
+
if (mime) {
121
146
const blob = new Blob([fileData], { type: mime });
122
147
decryptedBlob = blob;
123
123
-
setImageSrc(URL.createObjectURL(blob));
124
124
-
setImageAlt(name);
125
125
-
setContentType("image");
148
148
+
const url = URL.createObjectURL(blob);
149
149
+
if (IMAGE_EXTS.has(ext)) {
150
150
+
setImageSrc(url);
151
151
+
setContentType("image");
152
152
+
} else if (VIDEO_EXTS.has(ext)) {
153
153
+
setMediaSrc(url);
154
154
+
setContentType("video");
155
155
+
} else {
156
156
+
setMediaSrc(url);
157
157
+
setContentType("audio");
158
158
+
}
126
159
} else if (TEXT_EXTS.has(ext)) {
127
160
setTextContent(new TextDecoder().decode(fileData));
128
161
setContentType("text");
···
132
165
}
133
166
134
167
setStage("content");
135
135
-
} catch {
136
136
-
setError("Failed to decrypt. The key may be wrong.");
137
137
-
setLoadBtnDisabled(false);
138
138
-
setLoadBtnText("Retry");
168
168
+
} catch (e: any) {
169
169
+
setError(e.message || "Failed to decrypt.");
170
170
+
setStage("error");
139
171
}
140
172
};
141
173
142
174
const copyText = () => {
143
175
navigator.clipboard.writeText(textContent());
144
144
-
};
145
145
-
146
146
-
const viewRaw = () => {
147
147
-
const blob = new Blob([textContent()], { type: "text/plain" });
148
148
-
window.open(URL.createObjectURL(blob));
176
176
+
setCopied(true);
177
177
+
setTimeout(() => setCopied(false), 1500);
149
178
};
150
179
151
180
const saveFile = () => {
152
152
-
if (decryptedBlob) triggerDownload(decryptedBlob, fileName());
181
181
+
if (contentType() === "text") {
182
182
+
const blob = new Blob([textContent()], { type: "text/plain" });
183
183
+
triggerDownload(blob, fileName());
184
184
+
} else if (decryptedBlob) {
185
185
+
triggerDownload(decryptedBlob, fileName());
186
186
+
}
153
187
};
154
188
155
189
return (
156
190
<>
157
157
-
<Show when={stage() === "meta"}>
158
158
-
<div>
159
159
-
<div class="mb-1 text-base">Encrypted file</div>
160
160
-
<div class="text-muted mb-1 flex gap-3 text-xs">
161
161
-
<span>{formatBytes(size())}</span>
162
162
-
<span>{formatExpiry(expiresAt())}</span>
163
163
-
</div>
164
164
-
<Show when={burnAfterRead()}>
165
165
-
<div class="mb-3">
166
166
-
<span
167
167
-
class="text-danger inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
168
168
-
style={{
169
169
-
background:
170
170
-
"color-mix(in srgb, var(--color-danger) 10%, transparent)",
171
171
-
}}
172
172
-
>
173
173
-
Burns after viewing
174
174
-
</span>
175
175
-
</div>
191
191
+
<Show when={stage() === "loading"}>
192
192
+
<div class="flex justify-center">
193
193
+
<span class="text-muted text-xs">loading…</span>
194
194
+
</div>
195
195
+
</Show>
196
196
+
197
197
+
<Show when={stage() === "decrypting"}>
198
198
+
<div class="flex flex-col items-center gap-2" style={fadeIn}>
199
199
+
<Show
200
200
+
when={!decrypting()}
201
201
+
fallback={<span class="text-muted text-xs">decrypting…</span>}
202
202
+
>
203
203
+
<span
204
204
+
class="text-accent font-medium tabular-nums"
205
205
+
style={{ "font-size": "clamp(1.5rem, 5vw, 2.5rem)" }}
206
206
+
>
207
207
+
{progress()}%
208
208
+
</span>
209
209
+
<span class="text-muted text-xs">downloading…</span>
176
210
</Show>
177
177
-
<div class="mt-4">
178
178
-
<button
179
179
-
class="bg-accent hover:bg-accent-hover w-full rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-40"
180
180
-
disabled={loadBtnDisabled()}
181
181
-
onClick={handleView}
182
182
-
>
183
183
-
{loadBtnText()}
184
184
-
</button>
185
185
-
</div>
211
211
+
</div>
212
212
+
</Show>
213
213
+
214
214
+
<Show when={stage() === "meta"}>
215
215
+
<div class="flex flex-col items-center gap-3" style={fadeIn}>
216
216
+
<span
217
217
+
class="text-muted"
218
218
+
style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }}
219
219
+
>
220
220
+
{formatBytes(size())}
221
221
+
</span>
222
222
+
<button class={btnClass} style={btnStyle} onClick={handleView}>
223
223
+
view
224
224
+
</button>
225
225
+
<span class="text-muted text-xs">
226
226
+
{formatExpiry(expiresAt())} · burns after viewing
227
227
+
</span>
228
228
+
</div>
229
229
+
</Show>
230
230
+
231
231
+
<Show when={stage() === "error"}>
232
232
+
<div class="flex justify-center" style={fadeIn}>
233
233
+
<span class="text-danger text-sm">{error()}</span>
186
234
</div>
187
235
</Show>
188
236
189
237
<Show when={stage() === "content"}>
190
190
-
<Switch>
191
191
-
<Match when={contentType() === "text"}>
192
192
-
<div class="relative">
193
193
-
<div class="absolute top-2 right-2 z-10 flex gap-1">
194
194
-
<button
195
195
-
class="bg-surface text-muted hover:text-text border-border rounded-md border p-1.5 transition-colors"
196
196
-
title="Copy"
197
197
-
onClick={copyText}
198
198
-
>
199
199
-
<svg
200
200
-
xmlns="http://www.w3.org/2000/svg"
201
201
-
width="14"
202
202
-
height="14"
203
203
-
viewBox="0 0 24 24"
204
204
-
fill="none"
205
205
-
stroke="currentColor"
206
206
-
stroke-width="2"
207
207
-
stroke-linecap="round"
208
208
-
stroke-linejoin="round"
209
209
-
>
210
210
-
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
211
211
-
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
212
212
-
</svg>
238
238
+
<div class="mx-auto flex w-full flex-col gap-4" style={fadeIn}>
239
239
+
<div
240
240
+
class="flex items-center justify-between gap-4"
241
241
+
style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }}
242
242
+
>
243
243
+
<span class="text-text flex min-w-0 gap-1.5">
244
244
+
<span class="truncate">{fileName()}</span>
245
245
+
<span class="text-muted shrink-0 font-medium">
246
246
+
{formatBytes(size())}
247
247
+
</span>
248
248
+
</span>
249
249
+
<div class="flex shrink-0 items-center gap-2">
250
250
+
<Show when={contentType() === "text"}>
251
251
+
<button class={ghostClass} onClick={copyText}>
252
252
+
{copied() ? "copied!" : "copy"}
213
253
</button>
214
214
-
<button
215
215
-
class="bg-surface text-muted hover:text-text border-border rounded-md border p-1.5 transition-colors"
216
216
-
title="Raw"
217
217
-
onClick={viewRaw}
218
218
-
>
219
219
-
<svg
220
220
-
xmlns="http://www.w3.org/2000/svg"
221
221
-
width="14"
222
222
-
height="14"
223
223
-
viewBox="0 0 24 24"
224
224
-
fill="none"
225
225
-
stroke="currentColor"
226
226
-
stroke-width="2"
227
227
-
stroke-linecap="round"
228
228
-
stroke-linejoin="round"
229
229
-
>
230
230
-
<polyline points="4 7 4 4 20 4 20 7" />
231
231
-
<line x1="9" x2="15" y1="20" y2="20" />
232
232
-
<line x1="12" x2="12" y1="4" y2="20" />
233
233
-
</svg>
254
254
+
</Show>
255
255
+
<Show when={contentType() !== "binary"}>
256
256
+
<button class={ghostClass} onClick={saveFile}>
257
257
+
save
234
258
</button>
235
235
-
</div>
236
236
-
<div class="bg-surface border-border max-h-[60vh] w-full overflow-auto rounded-lg border p-4 pt-10 font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap">
237
237
-
{textContent()}
238
238
-
</div>
259
259
+
</Show>
239
260
</div>
240
240
-
</Match>
261
261
+
</div>
241
262
242
242
-
<Match when={contentType() === "image"}>
243
243
-
<div class="w-full">
263
263
+
<Show when={contentType() === "image"}>
264
264
+
<div class="bg-surface border-border flex items-center justify-center rounded-lg border p-4">
244
265
<img
245
266
src={imageSrc()}
246
246
-
alt={imageAlt()}
247
247
-
class="max-w-full rounded-lg shadow-sm"
267
267
+
alt={fileName()}
268
268
+
class="max-h-[70vh] w-fit max-w-full rounded"
248
269
/>
249
270
</div>
250
250
-
<div class="mt-3">
251
251
-
<button
252
252
-
class="bg-surface text-text border-border hover:bg-surface w-full rounded-md border py-2 text-sm font-medium transition-colors"
253
253
-
onClick={saveFile}
254
254
-
>
255
255
-
Save
256
256
-
</button>
271
271
+
</Show>
272
272
+
<Show when={contentType() === "video"}>
273
273
+
<div class="bg-surface border-border flex items-center justify-center rounded-lg border p-4">
274
274
+
<video
275
275
+
src={mediaSrc()}
276
276
+
controls
277
277
+
class="max-h-[70vh] w-fit max-w-full rounded"
278
278
+
/>
279
279
+
</div>
280
280
+
</Show>
281
281
+
<Show when={contentType() === "audio"}>
282
282
+
<div class="bg-surface border-border rounded-lg border p-4">
283
283
+
<audio src={mediaSrc()} controls class="w-full" />
284
284
+
</div>
285
285
+
</Show>
286
286
+
<Show when={contentType() === "text"}>
287
287
+
<div class="bg-surface border-border max-h-[70vh] w-full overflow-auto rounded-lg border p-4 font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap">
288
288
+
{textContent()}
257
289
</div>
258
258
-
</Match>
259
259
-
260
260
-
<Match when={contentType() === "binary"}>
261
261
-
<div class="bg-surface border-border rounded-lg border p-4 text-center">
262
262
-
<p class="mb-3 text-sm">{fileName()}</p>
263
263
-
<button
264
264
-
class="bg-accent hover:bg-accent-hover w-full rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors"
265
265
-
onClick={saveFile}
266
266
-
>
267
267
-
Download
290
290
+
</Show>
291
291
+
<Show when={contentType() === "binary"}>
292
292
+
<div class="flex justify-center">
293
293
+
<button class={btnClass} style={btnStyle} onClick={saveFile}>
294
294
+
download
268
295
</button>
269
296
</div>
270
270
-
</Match>
271
271
-
</Switch>
297
297
+
</Show>
272
298
273
273
-
<Show when={burned()}>
274
274
-
<div class="text-danger mt-3 text-xs">
275
275
-
This file has been burned and can no longer be viewed.
299
299
+
<div class="flex items-center justify-between">
300
300
+
<span class={`text-xs ${burned() ? "text-danger" : "text-muted"}`}>
301
301
+
{burned() ? "burned" : formatExpiry(expiresAt())}
302
302
+
</span>
303
303
+
<a href="/" class={ghostClass}>
304
304
+
new drop
305
305
+
</a>
276
306
</div>
277
277
-
</Show>
278
278
-
</Show>
279
279
-
280
280
-
<Show when={stage() === "error"}>
281
281
-
<div class="text-danger mt-4 text-xs">{error()}</div>
307
307
+
</div>
282
308
</Show>
283
309
</>
284
310
);
···
16
16
}
17
17
18
18
:root {
19
19
-
--c-bg: #faf7f4;
20
20
-
--c-surface: #f0ebe4;
21
21
-
--c-border: #ddd3c8;
19
19
+
--c-bg: #fdf8f2;
20
20
+
--c-surface: #f5ede0;
21
21
+
--c-border: #e0d0c0;
22
22
--c-text: #1c1410;
23
23
-
--c-muted: #8c7b6e;
24
24
-
--c-accent: #9b5e23;
25
25
-
--c-accent-hover: #7c4a1a;
26
26
-
--c-accent-subtle: #f5ece3;
23
23
+
--c-muted: #9a8070;
24
24
+
--c-accent: #b08855;
25
25
+
--c-accent-hover: #8f6e3f;
26
26
+
--c-accent-subtle: #f5e8d5;
27
27
--c-danger: #b91c1c;
28
28
+
}
29
29
+
30
30
+
@keyframes fade-in {
31
31
+
0% {
32
32
+
opacity: 0;
33
33
+
transform: translateY(4px);
34
34
+
}
35
35
+
100% {
36
36
+
opacity: 1;
37
37
+
transform: translateY(0);
38
38
+
}
28
39
}
29
40
30
41
@media (prefers-color-scheme: dark) {