From e8e091bc144a34b852f73cf65c1b22939e44969c Mon Sep 17 00:00:00 2001 From: execut4ble Date: Tue, 13 May 2025 23:20:17 +0300 Subject: [PATCH 1/7] Basic shoutbox component --- src/lib/components.ts | 1 + src/lib/components/common/Shoutbox.svelte | 120 ++++++++++++++++++++++ src/lib/server/db/schema.ts | 7 ++ src/lib/server/db/validations.ts | 21 +++- src/routes/+layout.server.ts | 9 +- src/routes/+layout.svelte | 3 + src/routes/+page.server.ts | 19 ++++ 7 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/common/Shoutbox.svelte diff --git a/src/lib/components.ts b/src/lib/components.ts index 27fed65..a7fc018 100644 --- a/src/lib/components.ts +++ b/src/lib/components.ts @@ -17,3 +17,4 @@ 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 Shoutbox } from "$lib/components/common/Shoutbox.svelte"; diff --git a/src/lib/components/common/Shoutbox.svelte b/src/lib/components/common/Shoutbox.svelte new file mode 100644 index 0000000..a1fdfe8 --- /dev/null +++ b/src/lib/components/common/Shoutbox.svelte @@ -0,0 +1,120 @@ + + +
+

Shoutbox

+ +
{ + return async ({ update, result }) => { + if ((result as EnhancedResult).data) { + errors = (result as EnhancedResult).data.errors; + } + await update(); + }; + }} + > + + +
+ +
+ {message.length}/150 +
+
+ + {#if errors?.author} + + {/if} + {#if errors?.content} + + {/if} + +
+ + diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 6501c0b..ca13f85 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -58,6 +58,13 @@ export const comment = pgTable("comment", { content: text("content").notNull(), }); +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 c709633..a5cb2b0 100644 --- a/src/lib/server/db/validations.ts +++ b/src/lib/server/db/validations.ts @@ -1,8 +1,27 @@ import { createInsertSchema, createUpdateSchema } from "drizzle-zod"; import validator from "validator"; -import { comment, event, post } from "./schema"; +import { comment, event, post, shout } from "./schema"; 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 2771fed..b475811 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,7 +1,9 @@ import { db } from "$lib/server/db"; import { sql } from "drizzle-orm"; +import * as table from "$lib/server/db/schema"; import type { LayoutServerLoad } from "./$types"; import type { RecentCommentsData } from "$lib/types"; +import { asc } from "drizzle-orm"; export const load: LayoutServerLoad = async (event) => { const visibilityClause = event.locals.user @@ -25,10 +27,15 @@ export const load: LayoutServerLoad = async (event) => { ORDER BY c.date DESC LIMIT 5;`); + const shouts = await db + .select() + .from(table.shout) + .orderBy(asc(table.shout.date)); + // Convert dates to ISO-8601 format (with timezone) for (const i in recentComments) { recentComments[i].date = new Date(recentComments[i].date).toISOString(); } - return { user: event.locals.user, recentComments }; + return { user: event.locals.user, recentComments, shouts }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 73be704..d697a1b 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"; @@ -14,6 +15,7 @@ let { data, children }: LayoutProps = $props(); let user: UserInfoData = $derived(data.user); let recentComments: RecentCommentsData = $derived(data.recentComments); + let shouts = $derived(data.shouts);
@@ -37,6 +39,7 @@
+ diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 325232b..be7d757 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,19 @@ 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 }); + } + } + }, +}; From 9e9d33128f75ecf5c01dd633bc3203570caebcd2 Mon Sep 17 00:00:00 2001 From: execut4ble Date: Wed, 14 May 2025 22:17:06 +0300 Subject: [PATCH 2/7] Shouts API endpoint and basic offset changing functionality --- src/lib/components/common/Shoutbox.svelte | 37 +++++++++++++++++++++-- src/routes/+layout.server.ts | 14 +++------ src/routes/+page.server.ts | 4 +-- src/routes/api/shouts/+server.ts | 19 ++++++++++++ src/routes/blog/+page.server.ts | 5 ++- src/routes/events/+page.server.ts | 2 +- 6 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 src/routes/api/shouts/+server.ts diff --git a/src/lib/components/common/Shoutbox.svelte b/src/lib/components/common/Shoutbox.svelte index a1fdfe8..f95fa6f 100644 --- a/src/lib/components/common/Shoutbox.svelte +++ b/src/lib/components/common/Shoutbox.svelte @@ -1,6 +1,5 @@
@@ -84,16 +95,23 @@ {/if} +
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index b475811..f2abb4b 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,12 +1,11 @@ import { db } from "$lib/server/db"; import { sql } from "drizzle-orm"; -import * as table from "$lib/server/db/schema"; + import type { LayoutServerLoad } from "./$types"; import type { RecentCommentsData } from "$lib/types"; -import { asc } from "drizzle-orm"; -export const load: LayoutServerLoad = async (event) => { - const visibilityClause = event.locals.user +export const load: LayoutServerLoad = async ({ fetch, locals }) => { + const visibilityClause = locals.user ? sql`` : sql`AND (c.event_id IS NULL OR e.is_visible = TRUE)`; @@ -27,15 +26,12 @@ export const load: LayoutServerLoad = async (event) => { ORDER BY c.date DESC LIMIT 5;`); - const shouts = await db - .select() - .from(table.shout) - .orderBy(asc(table.shout.date)); + 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: event.locals.user, recentComments, shouts }; + return { user: locals.user, recentComments, shouts: await shouts.json() }; }; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index be7d757..f1ca827 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,4 +1,4 @@ -import type { EventsArray } from "$lib/types"; +import type { EventsArray, PostsArray } from "$lib/types"; import type { PageServerLoad } from "./$types"; import { db } from "$lib/server/db"; import { sql } from "drizzle-orm"; @@ -10,7 +10,7 @@ import { shoutInsertSchema } from "$lib/server/db/validations"; export const load = (async ( event, -): Promise<{ events: EventsArray; recentPost }> => { +): Promise<{ events: EventsArray; recentPost: PostsArray }> => { const visibilityClause = event.locals.user ? sql`` : sql`AND is_visible = TRUE`; diff --git a/src/routes/api/shouts/+server.ts b/src/routes/api/shouts/+server.ts new file mode 100644 index 0000000..6b6bd56 --- /dev/null +++ b/src/routes/api/shouts/+server.ts @@ -0,0 +1,19 @@ +import { db } from "$lib/server/db"; +import { json, type RequestHandler } from "@sveltejs/kit"; +import * as table from "$lib/server/db/schema"; +import { 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) + ).reverse(); + + return json(shouts); +}; diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts index 35bd26e..d3a1d5a 100644 --- a/src/routes/blog/+page.server.ts +++ b/src/routes/blog/+page.server.ts @@ -4,8 +4,11 @@ import { count, sql } from "drizzle-orm"; import * as table from "$lib/server/db/schema"; import { eq, desc } from "drizzle-orm"; import { postActions } from "$lib/formActions/postActions"; +import type { PostsArray } from "$lib/types"; -export const load = (async ({ url }): Promise<{ posts; meta }> => { +export const load = (async ({ + url, +}): Promise<{ posts: PostsArray; meta: { totalPosts: number }[] }> => { const limit = Number(url.searchParams.get("limit")) || 5; const posts = await db .select({ diff --git a/src/routes/events/+page.server.ts b/src/routes/events/+page.server.ts index 9075cc7..c054d92 100644 --- a/src/routes/events/+page.server.ts +++ b/src/routes/events/+page.server.ts @@ -9,7 +9,7 @@ import { eq } from "drizzle-orm"; export const load = (async ({ locals, url, -}): Promise<{ events: EventsArray; meta }> => { +}): Promise<{ events: EventsArray; meta: { totalEvents: number }[] }> => { const visibilityClause = locals.user ? sql`` : sql`AND is_visible = TRUE`; const limit = Number(url.searchParams.get("limit")) || 5; From c7f5f9fbfbc6c330caf8b761282619f731dcacc4 Mon Sep 17 00:00:00 2001 From: execut4ble Date: Wed, 21 May 2025 00:27:16 +0300 Subject: [PATCH 3/7] Add shoutbox pagination --- src/lib/components/common/Shoutbox.svelte | 64 +++++++++++++++++++---- src/routes/api/shouts/+server.ts | 22 ++++---- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/src/lib/components/common/Shoutbox.svelte b/src/lib/components/common/Shoutbox.svelte index f95fa6f..1afa051 100644 --- a/src/lib/components/common/Shoutbox.svelte +++ b/src/lib/components/common/Shoutbox.svelte @@ -16,7 +16,17 @@ let author = $state(""); let message = $state(""); let errors: ValidationErrors | undefined = $state(); - let offset: number = $state(0); + + let currentPage = $state(0); + + let perPage = 5; + let totalRows = $derived(shouts.meta[0].totalRows); + + let totalPages = $derived(Math.ceil(totalRows / perPage)); + let start = $derived(currentPage * perPage); + let end = $derived( + currentPage === totalPages - 1 ? totalRows - 1 : start + perPage - 1, + ); async function fetchShouts(offset: number) { const res = await fetch(`/api/shouts?offset=${offset}`); @@ -31,8 +41,8 @@

Shoutbox

    - {#each shouts as shout (shout.id)} -
  • + {#each shouts.data as shout (shout.id)} +
  • @@ -52,6 +62,34 @@
    Nothing here. Write something!
    {/each}
+ {#if totalRows && totalRows > perPage} + + {/if}
@@ -95,18 +134,11 @@ {/if} -
diff --git a/src/routes/api/shouts/+server.ts b/src/routes/api/shouts/+server.ts index 6b6bd56..cd20498 100644 --- a/src/routes/api/shouts/+server.ts +++ b/src/routes/api/shouts/+server.ts @@ -1,19 +1,21 @@ import { db } from "$lib/server/db"; import { json, type RequestHandler } from "@sveltejs/kit"; import * as table from "$lib/server/db/schema"; -import { desc } from "drizzle-orm"; +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) - ).reverse(); + const shouts = await db + .select() + .from(table.shout) + .orderBy(desc(table.shout.date)) + .limit(5) + .offset(offset); - return json(shouts); + const meta = await db.select({ totalRows: count() }).from(table.shout); + + const response = { data: shouts, meta }; + + return json(response); }; From d40fd07d97c03af03a42ac5931b077d8496093de Mon Sep 17 00:00:00 2001 From: execut4ble Date: Fri, 23 May 2025 00:03:04 +0300 Subject: [PATCH 4/7] Ability to delete shoutbox messages by logged in users --- src/lib/components/common/Shoutbox.svelte | 25 ++++++++++++++++++++++- src/routes/+page.server.ts | 10 +++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/components/common/Shoutbox.svelte b/src/lib/components/common/Shoutbox.svelte index 1afa051..16476c3 100644 --- a/src/lib/components/common/Shoutbox.svelte +++ b/src/lib/components/common/Shoutbox.svelte @@ -3,6 +3,9 @@ import { relativeTime } from "svelte-relative-time"; import { slide } from "svelte/transition"; import FieldError from "./FieldError.svelte"; + import { page } from "$app/state"; + import Fa from "svelte-fa"; + import { faTrash } from "@fortawesome/free-solid-svg-icons"; type ValidationErrors = { author: string[]; content: string[] }; type EnhancedResult = { @@ -28,6 +31,8 @@ currentPage === totalPages - 1 ? totalRows - 1 : start + perPage - 1, ); + let hoveredItem = $state(null); + async function fetchShouts(offset: number) { const res = await fetch(`/api/shouts?offset=${offset}`); if (res.ok) { @@ -42,13 +47,26 @@

Shoutbox

    {#each shouts.data as shout (shout.id)} -
  • +
  • (hoveredItem = shout.id)} + onmouseover={() => (hoveredItem = shout.id)} + onmouseleave={() => (hoveredItem = null)} + >
    {shout.author}
    + {#if page.data.user && hoveredItem === shout.id} +
    + + +
    + {/if}
    diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index f1ca827..609e343 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -80,4 +80,14 @@ export const actions: Actions = { } } }, + 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)); + }, }; From dd84e8050aeab21c827318fbce9e6e9f6cb51f11 Mon Sep 17 00:00:00 2001 From: execut4ble Date: Sun, 25 May 2025 22:33:18 +0300 Subject: [PATCH 5/7] Add a toggle for shoutbox input form --- src/app.css | 2 +- src/lib/components/common/Shoutbox.svelte | 102 ++++++++++++---------- 2 files changed, 57 insertions(+), 47 deletions(-) diff --git a/src/app.css b/src/app.css index 9b9323f..17d2790 100644 --- a/src/app.css +++ b/src/app.css @@ -86,6 +86,7 @@ input { font-size: inherit; font-family: inherit; color: var(--color-text); + accent-color: var(--color-text-2); } button { @@ -173,7 +174,6 @@ form input { font-weight: 400; font-size: 1rem; padding: 12px 10px; - accent-color: var(--color-text-2); } ::selection { diff --git a/src/lib/components/common/Shoutbox.svelte b/src/lib/components/common/Shoutbox.svelte index 16476c3..50e7d29 100644 --- a/src/lib/components/common/Shoutbox.svelte +++ b/src/lib/components/common/Shoutbox.svelte @@ -19,12 +19,9 @@ let author = $state(""); let message = $state(""); let errors: ValidationErrors | undefined = $state(); - let currentPage = $state(0); - let perPage = 5; let totalRows = $derived(shouts.meta[0].totalRows); - let totalPages = $derived(Math.ceil(totalRows / perPage)); let start = $derived(currentPage * perPage); let end = $derived( @@ -32,6 +29,7 @@ ); let hoveredItem = $state(null); + let displayInputForm = $state(false); async function fetchShouts(offset: number) { const res = await fetch(`/api/shouts?offset=${offset}`); @@ -60,7 +58,7 @@
    {#if page.data.user && hoveredItem === shout.id} -
    + {/if} - { - return async ({ update, result }) => { - if ((result as EnhancedResult).data) { - errors = (result as EnhancedResult).data.errors; - } - await update(); - currentPage = 0; - }; - }} - > - - -
    - -
    - {message.length}/150 + {#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} - + {#if errors?.author} + + {/if} + {#if errors?.content} + + {/if} + + {:else} + + {/if}