Merge branch 'elysia-dev'
This commit is contained in:
+18
-3
@@ -29,7 +29,7 @@ export function renderLogin(message = "") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderAdmin(cards: BoardCard[], options: { storageMode: string }) {
|
export function renderAdmin(cards: BoardCard[], options: { storageMode: string; message?: string }) {
|
||||||
const sorted = [...cards].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
const sorted = [...cards].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||||
|
|
||||||
return pageShell(
|
return pageShell(
|
||||||
@@ -52,6 +52,12 @@ export function renderAdmin(cards: BoardCard[], options: { storageMode: string }
|
|||||||
</aside>
|
</aside>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
${
|
||||||
|
options.message
|
||||||
|
? `<section class="noticeBanner">${escapeHtml(options.message)}</section>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
<section class="workbench">
|
<section class="workbench">
|
||||||
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
|
<form class="editor" method="post" action="/admin/cards" enctype="multipart/form-data">
|
||||||
<p class="stamp">New card</p>
|
<p class="stamp">New card</p>
|
||||||
@@ -201,7 +207,8 @@ textarea {
|
|||||||
.mast,
|
.mast,
|
||||||
.editor,
|
.editor,
|
||||||
.cardEditor,
|
.cardEditor,
|
||||||
.empty {
|
.empty,
|
||||||
|
.noticeBanner {
|
||||||
border: 2px solid var(--color-line);
|
border: 2px solid var(--color-line);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
box-shadow: var(--shadow-hard);
|
box-shadow: var(--shadow-hard);
|
||||||
@@ -283,10 +290,18 @@ h1 {
|
|||||||
|
|
||||||
.editor,
|
.editor,
|
||||||
.cardEditor,
|
.cardEditor,
|
||||||
.empty {
|
.empty,
|
||||||
|
.noticeBanner {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noticeBanner {
|
||||||
|
margin-top: 28px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 18px;
|
top: 18px;
|
||||||
|
|||||||
+31
-19
@@ -104,6 +104,10 @@ async function cardFromForm(form: FormData, existing?: BoardCard): Promise<Board
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderAdminWithMessage(message: string, status = 500) {
|
||||||
|
return html(renderAdmin(await readCards(), { storageMode, message }), { status });
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(
|
.use(
|
||||||
swagger({
|
swagger({
|
||||||
@@ -246,17 +250,21 @@ const app = new Elysia()
|
|||||||
return redirect("/admin");
|
return redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await request.formData();
|
try {
|
||||||
const cards = await readCards();
|
const form = await request.formData();
|
||||||
const id = String(form.get("id") || "");
|
const cards = await readCards();
|
||||||
const existing = cards.find((card) => card.id === id);
|
const id = String(form.get("id") || "");
|
||||||
const nextCard = await cardFromForm(form, existing);
|
const existing = cards.find((card) => card.id === id);
|
||||||
const nextCards = existing
|
const nextCard = await cardFromForm(form, existing);
|
||||||
? cards.map((card) => (card.id === existing.id ? nextCard : card))
|
const nextCards = existing
|
||||||
: [nextCard, ...cards];
|
? cards.map((card) => (card.id === existing.id ? nextCard : card))
|
||||||
|
: [nextCard, ...cards];
|
||||||
|
|
||||||
await writeCards(nextCards);
|
await writeCards(nextCards);
|
||||||
return redirect("/admin");
|
return redirect("/admin");
|
||||||
|
} catch (error) {
|
||||||
|
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
detail: {
|
detail: {
|
||||||
tags: ["admin"],
|
tags: ["admin"],
|
||||||
@@ -269,17 +277,21 @@ const app = new Elysia()
|
|||||||
return redirect("/admin");
|
return redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await request.formData();
|
try {
|
||||||
const id = String(form.get("id") || "");
|
const form = await request.formData();
|
||||||
const cards = await readCards();
|
const id = String(form.get("id") || "");
|
||||||
const existing = cards.find((card) => card.id === id);
|
const cards = await readCards();
|
||||||
|
const existing = cards.find((card) => card.id === id);
|
||||||
|
|
||||||
if (existing?.imageUrl) {
|
if (existing?.imageUrl) {
|
||||||
await deleteRemoteImage(existing.imageUrl);
|
await deleteRemoteImage(existing.imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeCards(cards.filter((card) => card.id !== id));
|
||||||
|
return redirect("/admin");
|
||||||
|
} catch (error) {
|
||||||
|
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeCards(cards.filter((card) => card.id !== id));
|
|
||||||
return redirect("/admin");
|
|
||||||
}, {
|
}, {
|
||||||
detail: {
|
detail: {
|
||||||
tags: ["admin"],
|
tags: ["admin"],
|
||||||
|
|||||||
+20
-5
@@ -17,10 +17,13 @@ const localDataDir = path.resolve(".data");
|
|||||||
const localUploadsDir = path.join(localDataDir, "uploads");
|
const localUploadsDir = path.join(localDataDir, "uploads");
|
||||||
const localCardsPath = path.join(localDataDir, "cards.json");
|
const localCardsPath = path.join(localDataDir, "cards.json");
|
||||||
|
|
||||||
export const storageMode = process.env.BLOB_READ_WRITE_TOKEN ? "Vercel Blob" : "Local file";
|
const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim());
|
||||||
|
const isVercel = () => process.env.VERCEL === "1";
|
||||||
|
|
||||||
|
export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file";
|
||||||
|
|
||||||
export async function readCards(): Promise<BoardCard[]> {
|
export async function readCards(): Promise<BoardCard[]> {
|
||||||
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
if (hasBlobToken()) {
|
||||||
try {
|
try {
|
||||||
const result = await get(dataBlobPath, { access: "private" });
|
const result = await get(dataBlobPath, { access: "private" });
|
||||||
if (!result?.stream) {
|
if (!result?.stream) {
|
||||||
@@ -47,7 +50,7 @@ export async function readCards(): Promise<BoardCard[]> {
|
|||||||
export async function writeCards(cards: BoardCard[]) {
|
export async function writeCards(cards: BoardCard[]) {
|
||||||
const content = JSON.stringify(cards, null, 2);
|
const content = JSON.stringify(cards, null, 2);
|
||||||
|
|
||||||
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
if (hasBlobToken()) {
|
||||||
await put(dataBlobPath, content, {
|
await put(dataBlobPath, content, {
|
||||||
access: "private",
|
access: "private",
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@@ -56,6 +59,7 @@ export async function writeCards(cards: BoardCard[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertWritableLocalStorage();
|
||||||
await mkdir(localDataDir, { recursive: true });
|
await mkdir(localDataDir, { recursive: true });
|
||||||
await writeFile(localCardsPath, content, "utf8");
|
await writeFile(localCardsPath, content, "utf8");
|
||||||
}
|
}
|
||||||
@@ -64,7 +68,7 @@ export async function saveImage(file: File) {
|
|||||||
const safeName = safeFileName(file.name || "image");
|
const safeName = safeFileName(file.name || "image");
|
||||||
const pathname = `pozor/images/${Date.now()}-${safeName}`;
|
const pathname = `pozor/images/${Date.now()}-${safeName}`;
|
||||||
|
|
||||||
if (process.env.BLOB_READ_WRITE_TOKEN) {
|
if (hasBlobToken()) {
|
||||||
const blob = await put(pathname, file, {
|
const blob = await put(pathname, file, {
|
||||||
access: "public",
|
access: "public",
|
||||||
contentType: file.type || "application/octet-stream"
|
contentType: file.type || "application/octet-stream"
|
||||||
@@ -72,6 +76,7 @@ export async function saveImage(file: File) {
|
|||||||
return blob.url;
|
return blob.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertWritableLocalStorage();
|
||||||
await mkdir(localUploadsDir, { recursive: true });
|
await mkdir(localUploadsDir, { recursive: true });
|
||||||
const localName = `${Date.now()}-${safeName}`;
|
const localName = `${Date.now()}-${safeName}`;
|
||||||
const localPath = path.join(localUploadsDir, localName);
|
const localPath = path.join(localUploadsDir, localName);
|
||||||
@@ -80,7 +85,7 @@ export async function saveImage(file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRemoteImage(imageUrl: string) {
|
export async function deleteRemoteImage(imageUrl: string) {
|
||||||
if (!process.env.BLOB_READ_WRITE_TOKEN || !imageUrl.includes(".blob.vercel-storage.com/")) {
|
if (!hasBlobToken() || !imageUrl.includes(".blob.vercel-storage.com/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +100,16 @@ export function localUploadPath(filename: string) {
|
|||||||
return path.join(localUploadsDir, safeFileName(filename));
|
return path.join(localUploadsDir, safeFileName(filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertWritableLocalStorage() {
|
||||||
|
if (!isVercel()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"BLOB_READ_WRITE_TOKEN is not set for the API deployment. Vercel functions cannot write to .data; connect Vercel Blob to the API project and redeploy."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function publicCards(cards: BoardCard[]) {
|
export function publicCards(cards: BoardCard[]) {
|
||||||
return cards
|
return cards
|
||||||
.filter((card) => card.published)
|
.filter((card) => card.published)
|
||||||
|
|||||||
Reference in New Issue
Block a user