Skip to content
9 changes: 7 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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!"
}
9 changes: 7 additions & 2 deletions messages/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ä",
Expand All @@ -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!"
}
9 changes: 7 additions & 2 deletions messages/lt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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ą!"
}
3 changes: 2 additions & 1 deletion src/lib/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
254 changes: 254 additions & 0 deletions src/lib/components/common/Shoutbox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
<script lang="ts">
import { enhance } from "$app/forms";
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";
import { m } from "$lib/paraglide/messages";
import { getLocale } from "$lib/paraglide/runtime";

type ValidationErrors = { author: string[]; content: string[] };
type EnhancedResult = {
data: {
errors?: ValidationErrors;
};
};

let { shouts } = $props();

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(
currentPage === totalPages - 1 ? totalRows - 1 : start + perPage - 1,
);

let hoveredItem = $state(null);
let displayInputForm = $state(false);

async function fetchShouts(offset: number) {
const res = await fetch(`/api/shouts?offset=${offset}`);
if (res.ok) {
shouts = await res.json();
} else {
console.error("Failed to fetch shouts:", res.statusText);
}
}
</script>

<div class="shoutbox">
<h3><strong>{m.shoutbox()}</strong></h3>
<ul>
{#each shouts.data as shout (shout.id)}
<li
class="shout"
onfocus={() => (hoveredItem = shout.id)}
onmouseover={() => (hoveredItem = shout.id)}
onmouseleave={() => (hoveredItem = null)}
>
<div class="heading">
<div class="author" title={shout.author}>
<strong>
{shout.author}
</strong>
</div>
{#if page.data.user && hoveredItem === shout.id}
<form method="POST" action="/?/remove_shout" use:enhance>
<input type="hidden" name="id" value={shout.id} />
<button type="submit" class="post action">
<Fa icon={faTrash} /></button
>
</form>
{/if}
<div
class="font-size-small dim date"
use:relativeTime={{
date: new Date(shout.date),
locale: getLocale(),
}}
></div>
</div>
<div class="content">
{shout.content}
</div>
</li>
{:else}
<div transition:slide>{m.shoutbox_empty()}</div>
{/each}
</ul>
{#if totalRows && totalRows > perPage}
<div class="pagination">
{#if currentPage !== 0}
<button
onclick={() => {
currentPage -= 1;
fetchShouts(start);
}}
aria-label="left arrow icon"
aria-describedby="prev"
>&lt;
</button>
{/if}
<p>{start + 1} - {end + 1} of {totalRows}</p>
{#if currentPage !== totalPages - 1}
<button
onclick={() => {
currentPage += 1;
fetchShouts(currentPage === 0 ? end : start);
}}
aria-label="right arrow icon"
aria-describedby="next"
>
&gt;
</button>
{/if}
</div>
{/if}
{#if displayInputForm}
<form
class="add-shout"
method="POST"
action="/?/add_shout"
use:enhance={() => {
return async ({ update, result }) => {
if ((result as EnhancedResult).data) {
errors = (result as EnhancedResult).data.errors;
}
await update();
currentPage = 0;
};
}}
>
<input
id="author"
name="author"
placeholder={m["form.name"]()}
class="shoutbox"
bind:value={author}
maxlength="30"
required
/>
<textarea
id="content"
name="content"
placeholder={m["form.message"]()}
bind:value={message}
maxlength="150"
spellcheck="false"
required
autocomplete="off"
></textarea>
<div class="actions">
<button type="submit">{m.shoutbox_submit()}</button>
<div class="length">
{message.length}/150
</div>
</div>

{#if errors?.author}
<FieldError errors={errors?.author} />
{/if}
{#if errors?.content}
<FieldError errors={errors?.content} />
{/if}
</form>
{:else}
<button
id="areyouacop"
name="areyouacop"
onclick={() => (displayInputForm = true)}>{m.shoutbox_toggle()}</button
>
{/if}
</div>

<style>
li.shout:not(:last-child) {
padding-bottom: 1.5em;
}

li.shout .content {
word-break: break-word;
padding: 0.2em 0 0.2em 0;
}

li.shout .heading {
display: flex;
gap: 0.2em;
min-width: 0;
}

li.shout .date {
margin-left: auto;
padding-right: 1em;
white-space: nowrap;
}

li.shout .author {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

form .actions {
display: flex;
}

form .length {
margin-left: auto;
}

form.add-shout {
display: flex;
flex-direction: column;
}
form textarea {
max-height: 5em;
font-size: 10pt;
box-sizing: border-box;
width: 100%;
}

form input.shoutbox#author {
font-size: 10pt;
box-sizing: border-box;
width: 100%;
}

div.shoutbox {
width: 100%;
}

ul {
max-height: 25em;
overflow-y: scroll;
overflow-x: hidden;
max-width: 100%;
padding-left: 0;
list-style: none;
font-size: 0.75rem;
scrollbar-color: var(--color-text-2) var(--color-text);
}

div.pagination {
display: flex;
justify-content: center;
}

div.pagination p {
margin-top: 0;
margin-bottom: 0;
}

div.heading form {
margin-bottom: 0;
font-size: smaller;
}
</style>
7 changes: 7 additions & 0 deletions src/lib/server/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion src/lib/server/db/validations.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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() };
};
Loading
Loading