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 @@
+
+
+
+
+
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);
+};