diff --git a/messages/en.json b/messages/en.json index c0afd40..5d246f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -54,7 +54,8 @@ "select_image": "Select an image", "upload": "Upload", "uploading": "Uploading...", - "clear_image": "Clear image" + "clear_image": "Clear image", + "message": "Message" }, "draft": "DRAFT / NOT VISIBLE", "submit": "post", @@ -80,5 +81,9 @@ "blocked_ips_description": "These IP addresses are prohibited from posting comments.", "blocked_ips_no_entries": "Once you block an IP address it will appear here.", "blocked_on": "blocked on", - "block_ip": "block ip" + "block_ip": "block ip", + "shoutbox": "Shoutbox", + "shoutbox_empty": "Nothing here. Write something!", + "shoutbox_toggle": "Ż̴̼a̴͂ͅĺ̶͖g̷̋͜o̷̭̅ ̶̻̅l̵̯̍i̵̹͋s̶͎̿t̵͇̀e̸̯͑ṇ̸̽s̵͔̊.̶̪̏.̶̥͆.̸̲̆", + "shoutbox_submit": "Scream into the void!" } diff --git a/messages/fi.json b/messages/fi.json index efe4562..19e41d7 100644 --- a/messages/fi.json +++ b/messages/fi.json @@ -54,7 +54,8 @@ "select_image": "Valitse kuva", "upload": "Lataa", "uploading": "Ladataan...", - "clear_image": "Tyhjennä kuva" + "clear_image": "Tyhjennä kuva", + "message": "Viesti" }, "draft": "LUONNOS / EI NÄKYVISSÄ", "submit": "lähetä", @@ -80,5 +81,9 @@ "blocked_ips_description": "Näistä IP-osoitteista ei voi julkaista kommentteja.", "blocked_ips_no_entries": "Kun estät IP-osoitteen, se näkyy täällä.", "blocked_on": "estetty", - "block_ip": "estä IP-osoite" + "block_ip": "estä IP-osoite", + "shoutbox": "Huutolaatikko", + "shoutbox_empty": "Täällä ei ole mitään. Kirjoita jotain!", + "shoutbox_toggle": "Z̸̛̪̓å̸͍l̶̬̪̇̇́g̷̛̞̍o̸͓̝͉͌ ̵̧̖̏k̵̝͌̿ū̴̟̠̀̓ȕ̶̫̪̙͐n̷̠̈͊͘t̷̨͒̎̌ẻ̶̬̘̱̔ĺ̶̼̘e̶̥̲̽͋͝ͅḛ̴̜͚̈́͊͝.̷̨̦̘̐̄.̵͕̲̝̈́͝.̷͍̀ͅ", + "shoutbox_submit": "Huuda tyhjyyteen!" } diff --git a/messages/lt.json b/messages/lt.json index 5d3d067..f6f7403 100644 --- a/messages/lt.json +++ b/messages/lt.json @@ -54,7 +54,8 @@ "select_image": "Pasirinkti nuotrauką", "upload": "Įkelti", "uploading": "Įkeliama...", - "clear_image": "Panaikinti" + "clear_image": "Panaikinti", + "message": "Žinutė" }, "draft": "nepaskelbta / viešai nematoma", "submit": "būpt", @@ -80,5 +81,9 @@ "blocked_ips_description": "Šie IP adresai negali skelbti komentarų.", "blocked_ips_no_entries": "Užblokuoti IP adresai bus rodomi čia.", "blocked_on": "užblokuotas", - "block_ip": "užblokuoti ip" + "block_ip": "užblokuoti ip", + "shoutbox": "Šaukykla", + "shoutbox_empty": "Tuščia. Būk pirmas!", + "shoutbox_toggle": "Z̵̈́͜͝a̸̢̭̝̿͠ĺ̶̞͕g̴̺̃̾ó̴̹̺ ̶͖̮̞̑ḳ̵̅̒ḻ̶̛̳̒̔ä̸̘́̂͌ụ̵̦̜̆s̶͉̖͒͊͝ọ̸̱̆̅́s̸̛͍̰̓̊i̴̹͓̋̅.̸̼͗̐.̴̳͍̭̾.̴̮̲̭̀̀͛", + "shoutbox_submit": "Šauk į tuštumą!" } diff --git a/src/lib/components.ts b/src/lib/components.ts index 1cf1be6..9e2dd96 100644 --- a/src/lib/components.ts +++ b/src/lib/components.ts @@ -17,4 +17,5 @@ export { default as AddCommentForm } from "$lib/components/common/AddCommentForm export { default as RemoveItemForm } from "$lib/components/common/RemoveItemForm.svelte"; export { default as CommentCount } from "$lib/components/common/CommentCount.svelte"; export { default as MetaTags } from "$lib/components/common/MetaTags.svelte"; -export { default as BannedAddress } from "$lib/components/common/BannedAddress.svelte"; +export { default as Shoutbox } from "$lib/components/common/Shoutbox.svelte"; +export { default as BannedAddress } from "$lib/components/common/BannedAddress.svelte"; \ No newline at end of file diff --git a/src/lib/components/common/Shoutbox.svelte b/src/lib/components/common/Shoutbox.svelte new file mode 100644 index 0000000..908f720 --- /dev/null +++ b/src/lib/components/common/Shoutbox.svelte @@ -0,0 +1,254 @@ + + +
+

{m.shoutbox()}

+ + {#if totalRows && totalRows > perPage} + + {/if} + {#if displayInputForm} +
{ + return async ({ update, result }) => { + if ((result as EnhancedResult).data) { + errors = (result as EnhancedResult).data.errors; + } + await update(); + currentPage = 0; + }; + }} + > + + +
+ +
+ {message.length}/150 +
+
+ + {#if errors?.author} + + {/if} + {#if errors?.content} + + {/if} + + {:else} + + {/if} +
+ + diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 7a723f8..29dd031 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -65,6 +65,13 @@ export const bannedIp = pgTable("banned_ip", { date: timestamp("ban_date", { withTimezone: true }).notNull().defaultNow(), }); +export const shout = pgTable("shout", { + id: serial("id").primaryKey(), + author: text("author").notNull(), + date: timestamp("date", { withTimezone: true }).notNull().defaultNow(), + content: text("content").notNull(), +}); + export type Session = typeof session.$inferSelect; export type User = typeof user.$inferSelect; diff --git a/src/lib/server/db/validations.ts b/src/lib/server/db/validations.ts index 655e3f8..1b09d82 100644 --- a/src/lib/server/db/validations.ts +++ b/src/lib/server/db/validations.ts @@ -1,9 +1,28 @@ import { createInsertSchema, createUpdateSchema } from "drizzle-zod"; import validator from "validator"; -import { bannedIp, comment, event, post } from "./schema"; +import { bannedIp, comment, event, post, shout } from "./schema"; import { m } from "$lib/paraglide/messages.js"; import { z } from "zod"; +export const shoutInsertSchema = createInsertSchema(shout, { + author: (schema) => + schema + .min(1, { message: "Name is required" }) + .max(30, { message: "Name must be less than 30 characters" }) + .trim() + .refine((value) => !validator.isEmpty(value), { + message: "Name can't be empty", + }), + content: (schema) => + schema + .min(1, { message: "Shout can't be empty" }) + .max(150, { message: "Shout must be less than 250 characters" }) + .trim() + .refine((value) => !validator.isEmpty(value), { + message: "Shout can't be empty", + }), +}); + export const commentInsertSchema = createInsertSchema(comment, { author: (schema) => schema diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index f592f56..7e386f1 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,5 +1,6 @@ import { db } from "$lib/server/db"; import { sql } from "drizzle-orm"; + import type { LayoutServerLoad } from "./$types"; import type { RecentComment, RecentCommentsData } from "$lib/types"; @@ -33,15 +34,17 @@ const queryRecentComments = async ({ locals }) => { } }; -export const load: LayoutServerLoad = async ({ locals }) => { +export const load: LayoutServerLoad = async ({ fetch, locals }) => { const recentComments: RecentCommentsData = await queryRecentComments({ locals, }); + const shouts = await fetch("/api/shouts"); + // Convert dates to ISO-8601 format (with timezone) for (const i in recentComments) { recentComments[i].date = new Date(recentComments[i].date).toISOString(); } - return { user: locals.user, recentComments }; + return { user: locals.user, recentComments, shouts: await shouts.json() }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3c87abf..8b10aaa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,6 +7,7 @@ RecentComments, UserInfo, ThemeToggle, + Shoutbox, } from "$lib/components"; import type { LayoutProps } from "./$types"; import type { RecentCommentsData, UserInfoData } from "$lib/types"; @@ -15,6 +16,7 @@ let { data, children }: LayoutProps = $props(); let user: UserInfoData = $derived(data.user); let recentComments: RecentCommentsData = $derived(data.recentComments); + let shouts = $derived(data.shouts);
@@ -48,6 +50,7 @@
+ diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index ed78ef7..609e343 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -4,6 +4,9 @@ import { db } from "$lib/server/db"; import { sql } from "drizzle-orm"; import * as table from "$lib/server/db/schema"; import { eq, desc } from "drizzle-orm"; +import { fail, type Actions } from "@sveltejs/kit"; +import { z } from "zod"; +import { shoutInsertSchema } from "$lib/server/db/validations"; export const load = (async ( event, @@ -62,3 +65,29 @@ export const load = (async ( return { events, recentPost }; }) satisfies PageServerLoad; + +export const actions: Actions = { + add_shout: async ({ request }) => { + const formData: FormData = await request.formData(); + const data: object = Object.fromEntries(formData.entries()); + try { + const shout = shoutInsertSchema.parse(data); + await db.insert(table.shout).values(shout); + } catch (err) { + if (err instanceof z.ZodError) { + const { fieldErrors: errors } = err.flatten(); + return fail(400, { errors }); + } + } + }, + remove_shout: async ({ request, locals }) => { + if (!locals.session) { + return fail(401); + } + const formData: FormData = await request.formData(); + const shoutId: FormDataEntryValue | null = formData.get("id"); + await db + .delete(table.shout) + .where(eq(table.shout.id, shoutId as unknown as number)); + }, +}; diff --git a/src/routes/api/shouts/+server.ts b/src/routes/api/shouts/+server.ts new file mode 100644 index 0000000..cd20498 --- /dev/null +++ b/src/routes/api/shouts/+server.ts @@ -0,0 +1,21 @@ +import { db } from "$lib/server/db"; +import { json, type RequestHandler } from "@sveltejs/kit"; +import * as table from "$lib/server/db/schema"; +import { count, desc } from "drizzle-orm"; + +export const GET: RequestHandler = async ({ url }) => { + const offset = Number(url.searchParams.get("offset")) || 0; + + const shouts = await db + .select() + .from(table.shout) + .orderBy(desc(table.shout.date)) + .limit(5) + .offset(offset); + + const meta = await db.select({ totalRows: count() }).from(table.shout); + + const response = { data: shouts, meta }; + + return json(response); +};