feat: download message
This commit is contained in:
1 parent
b8f742f88d
commit
f02519b7ae
4 files changed
+126
-52
No files matched your search
+57
-36
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user