feat: download message

This commit is contained in:
lyonbot committed 2025-01-08 17:13:49 +08:00
1 parent b8f742f88d
commit f02519b7ae
4 files changed
+126 -52

No files matched your search

+57 -36
View File
@@ -16,9 +16,9 @@ export async function migrateTables(db: D1Database) {
export interface UploadRecord {
id: number;
uploader: string;
ctime: Date;
ctime: number;
size: number;
files: string; // JSON string of [{ name, size, path }]
files: RecordFileItem[];
message: string;
}
@@ -29,6 +29,44 @@ export interface RecordFileItem {
thumbnail?: string;
}
function fromDB(data: any): UploadRecord {
let files: RecordFileItem[] = [];
try {
files = JSON.parse(data.files);
} catch {}
return {
id: data.id,
uploader: data.uploader,
ctime: +new Date(data.ctime),
size: data.size,
files,
message: data.message,
};
}
function toDB(record: UploadRecord): any {
return {
id: record.id,
uploader: record.uploader,
ctime: new Date(+record.ctime),
size: record.size,
files: JSON.stringify(
Array.from(
record.files || [],
(item) =>
item && {
name: String(item.name),
size: +item.size,
path: String(item.path || ""),
thumbnail: String(item.thumbnail || ""),
}
).filter(Boolean)
),
message: record.message,
};
}
/**
* get latest 20 records. in descending order (newest first)
*
@@ -48,18 +86,20 @@ export async function getUploadRecords(
const rows = await db.prepare(sql).all();
for (const row of rows.results as any[]) {
records.push({
id: row.id,
uploader: row.uploader,
ctime: new Date(row.ctime),
size: row.size,
files: row.files,
message: row.message,
});
records.push(fromDB(row));
}
return records;
}
export async function getSingleUploadRecord(db: D1Database, id: number) {
const record = await db
.prepare(`SELECT * FROM upload_record WHERE id = ?`)
.bind(id)
.first<UploadRecord>();
if (!record) return null;
return fromDB(record);
}
export async function createUploadRecord(
db: D1Database,
record: Omit<UploadRecord, "id" | "ctime">
@@ -68,16 +108,11 @@ export async function createUploadRecord(
.prepare(
"INSERT INTO upload_record (uploader, size, files, message) VALUES (?, ?, ?, ?)"
)
.bind(
record.uploader,
record.size,
record.files,
record.message
)
.bind(record.uploader, record.size, record.files, record.message)
.run();
const id = res.meta.last_row_id;
const inserted: UploadRecord = {
...record,
...toDB(record as UploadRecord),
ctime: new Date(),
id,
};
@@ -98,15 +133,9 @@ export async function purgeRecordsBeforeId(
// 2. delete files
for (const row of rows.results) {
if (!row.files) continue;
try {
const files = JSON.parse(row.files) as {
name: string;
path: string;
size: number;
}[];
await deleteFiles(files.map((file) => file.path));
} catch {}
const files = fromDB(row).files;
if (!files.length) continue;
await deleteFiles(files.map((file) => file.path));
}
// 3. delete records
@@ -121,20 +150,12 @@ export async function deleteRecord(
deleteFiles: (path: string[]) => Promise<void>
) {
// 1. get record
const record = await db
.prepare(`SELECT * FROM upload_record WHERE id = ?`)
.bind(id)
.first<UploadRecord>();
const record = await getSingleUploadRecord(db, id);
if (!record) return;
// 2. delete files
if (!record.files) return;
try {
const files = JSON.parse(record.files) as {
name: string;
path: string;
size: number;
}[];
const files = record.files;
await deleteFiles(files.map((file) => file.path));
} catch {}
+24 -4
View File
@@ -2,6 +2,7 @@ import { Hono } from "hono";
import {
createUploadRecord,
deleteRecord,
getSingleUploadRecord,
getUploadRecords,
migrateTables,
purgeRecordsBeforeId,
@@ -65,17 +66,28 @@ app.post("/api/upload", async (c) => {
const record = await createUploadRecord(c.env.DB, {
uploader,
size: uploadedFiles.reduce((acc, file) => acc + file.size, 0),
files: JSON.stringify(uploadedFiles),
files: uploadedFiles,
message,
});
return c.json({ record });
});
app.get("/api/download", async (c) => {
const filePath = c.req.query("path");
app.get("/api/download/:id/:index", async (c) => {
const id = +c.req.param("id");
const index = c.req.param("index");
const record = await getSingleUploadRecord(c.env.DB, id);
if (!record) {
return c.status(404);
}
if (index === "message") {
return c.text(record.message);
}
const filePath = record.files[+index]?.path;
if (!filePath) {
return c.json({ error: "No file path" });
return c.status(404);
}
const r = await c.env.MY_BUCKET.get(filePath, {
@@ -87,8 +99,16 @@ app.get("/api/download", async (c) => {
return c.json({ error: "File not found" });
}
const basename = filePath.split("/").pop()!.replace(/\?.*/, "");
const headers = new Headers();
headers.set("accept-ranges", "bytes");
if (
!/\.(jpg|png|gif|avif|mp4|mov|txt|html|js|css|json|ya?ml)/.test(basename)
) {
headers.set("content-disposition", `attachment; filename="${basename}"`);
}
r.writeHttpMetadata(headers);
return new Response(r.body, { headers });
});
+20
View File
@@ -42,3 +42,23 @@ input[type='text'] {
font-size: inherit;
font-family: Menlo, Consolas, Roboto Mono, 'Source Code Pro', 'Courier New', Courier, monospace;
}
.popover-container {
position: relative;
.popover-content {
display: none;
position: absolute;
top: 100%;
left: 0;
width: max-content;
}
&:hover,
&:focus-within {
.popover-content {
display: flex;
flex-direction: column;
}
}
}
+25 -12
View File
@@ -1,6 +1,6 @@
import { memo, useEffect, useMemo } from 'react';
import useSWRInfinite from 'swr/infinite';
import type { RecordFileItem, UploadRecord } from '../../../src/database';
import type { UploadRecord } from '../../../src/database';
interface Props {}
@@ -38,10 +38,9 @@ export const UploadRecords = memo<Props>((props) => {
});
const UploadRecordItem = memo((props: { record: UploadRecord }) => {
const files = useMemo(() => {
if (!props.record.files) return [];
return JSON.parse(props.record.files) as RecordFileItem[];
}, [props.record.files]);
const files = useMemo(() => props.record.files || [], [props.record.files]);
const actionLink = 'cursor-pointer hover:text-black text-inherit decoration-none';
return (
<div className="p-4 rounded-lg bg-white shadow mb-2">
@@ -66,13 +65,27 @@ const UploadRecordItem = memo((props: { record: UploadRecord }) => {
</div>
{!!props.record.message && (
<span className="cursor-pointer hover:text-black" onClick={() => copyToClipboard(props.record.message)}>
<i className="i-mdi-clipboard mr-1"></i>
Copy Message
</span>
<div className="popover-container">
<div className={actionLink} onClick={() => copyToClipboard(props.record.message)}>
<i className="i-mdi-clipboard mr-1"></i>
Copy Message
</div>
<div className="popover-content bg-white min-w-full">
<a
className={actionLink}
target="_blank"
rel="noreferrer"
href={`/api/download/${encodeURIComponent(props.record.id)}/message`}
>
<i className="i-mdi-download mr-1"></i>
As File
</a>
</div>
</div>
)}
<span className="cursor-pointer hover:text-red" onClick={() => deleteRecord(props.record.id)}>
<span className={`${actionLink} hover:text-red`} onClick={() => deleteRecord(props.record.id)}>
<i className="i-mdi-trash mr-1"></i>
Delete
</span>
@@ -82,8 +95,8 @@ const UploadRecordItem = memo((props: { record: UploadRecord }) => {
{files.length > 0 && (
<div className="flex flex-wrap m--2">
{files.map((file) => {
const link = `/api/download?path=${encodeURIComponent(file.path)}`;
{files.map((file, index) => {
const link = `/api/download/${props.record.id}/${index}`;
return (
<div key={file.path} className="flex gap-2 p-2 max-w-sm min-w-0">
<a