summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--garden/public/images/backgrounds/background.webp (renamed from garden/public/images/background.webp)bin940 -> 940 bytes
-rw-r--r--garden/public/images/districts/arcadia.pngbin0 -> 799 bytes
-rw-r--r--garden/public/images/districts/arles.pngbin0 -> 649 bytes
-rw-r--r--garden/public/images/districts/hollywood.pngbin0 -> 742 bytes
-rw-r--r--garden/public/images/districts/oxford.pngbin0 -> 766 bytes
-rw-r--r--garden/public/images/districts/petsburg.pngbin0 -> 1031 bytes
-rw-r--r--garden/public/images/districts/purgatory.pngbin0 -> 1832 bytes
-rw-r--r--garden/public/images/districts/silicon.pngbin0 -> 806 bytes
-rw-r--r--garden/public/images/districts/silver.pngbin0 -> 720 bytes
-rw-r--r--garden/public/images/districts/stratford.pngbin0 -> 662 bytes
-rw-r--r--garden/public/images/districts/tokyo.pngbin0 -> 1413 bytes
-rw-r--r--garden/src/index.css3
-rw-r--r--garden/src/pages/council/districts.tsx350
-rw-r--r--garden/src/pages/districts/district.tsx145
-rw-r--r--garden/src/pages/districts/index.tsx57
-rw-r--r--garden/src/pages/districts/submit.tsx171
-rw-r--r--garden/src/routes.ts4
-rw-r--r--garden/src/styles/districts.css416
-rw-r--r--garden/src/styles/layout.css5
-rw-r--r--garden/src/types/admin.ts3
-rw-r--r--garden/src/types/district.ts40
-rw-r--r--garden/src/utils/districts.ts30
-rw-r--r--shrine/controllers/district.go98
-rw-r--r--shrine/database/migrate.go2
-rw-r--r--shrine/enums/district.go25
-rw-r--r--shrine/go.mod11
-rw-r--r--shrine/go.sum20
-rw-r--r--shrine/jobs/scheduler.go26
-rw-r--r--shrine/messages/district.go21
-rw-r--r--shrine/models/district.go99
-rw-r--r--shrine/repositories/district.go133
-rw-r--r--shrine/router/council.go6
-rw-r--r--shrine/router/district.go16
-rw-r--r--shrine/services/district.go249
-rw-r--r--shrine/services/thumbnail.go151
-rw-r--r--shrine/shrine/main.go3
-rw-r--r--shrine/types/district/request.go20
-rw-r--r--shrine/types/district/response.go48
-rw-r--r--shrine/utils/districts/registry.go44
39 files changed, 2191 insertions, 5 deletions
diff --git a/garden/public/images/background.webp b/garden/public/images/backgrounds/background.webp
index 9e37b7f..9e37b7f 100644
--- a/garden/public/images/background.webp
+++ b/garden/public/images/backgrounds/background.webp
Binary files differ
diff --git a/garden/public/images/districts/arcadia.png b/garden/public/images/districts/arcadia.png
new file mode 100644
index 0000000..821955e
--- /dev/null
+++ b/garden/public/images/districts/arcadia.png
Binary files differ
diff --git a/garden/public/images/districts/arles.png b/garden/public/images/districts/arles.png
new file mode 100644
index 0000000..43322e2
--- /dev/null
+++ b/garden/public/images/districts/arles.png
Binary files differ
diff --git a/garden/public/images/districts/hollywood.png b/garden/public/images/districts/hollywood.png
new file mode 100644
index 0000000..56e52a2
--- /dev/null
+++ b/garden/public/images/districts/hollywood.png
Binary files differ
diff --git a/garden/public/images/districts/oxford.png b/garden/public/images/districts/oxford.png
new file mode 100644
index 0000000..d1016d2
--- /dev/null
+++ b/garden/public/images/districts/oxford.png
Binary files differ
diff --git a/garden/public/images/districts/petsburg.png b/garden/public/images/districts/petsburg.png
new file mode 100644
index 0000000..a8bb491
--- /dev/null
+++ b/garden/public/images/districts/petsburg.png
Binary files differ
diff --git a/garden/public/images/districts/purgatory.png b/garden/public/images/districts/purgatory.png
new file mode 100644
index 0000000..85aac4f
--- /dev/null
+++ b/garden/public/images/districts/purgatory.png
Binary files differ
diff --git a/garden/public/images/districts/silicon.png b/garden/public/images/districts/silicon.png
new file mode 100644
index 0000000..bf630f9
--- /dev/null
+++ b/garden/public/images/districts/silicon.png
Binary files differ
diff --git a/garden/public/images/districts/silver.png b/garden/public/images/districts/silver.png
new file mode 100644
index 0000000..250613a
--- /dev/null
+++ b/garden/public/images/districts/silver.png
Binary files differ
diff --git a/garden/public/images/districts/stratford.png b/garden/public/images/districts/stratford.png
new file mode 100644
index 0000000..ba18bc5
--- /dev/null
+++ b/garden/public/images/districts/stratford.png
Binary files differ
diff --git a/garden/public/images/districts/tokyo.png b/garden/public/images/districts/tokyo.png
new file mode 100644
index 0000000..56d1843
--- /dev/null
+++ b/garden/public/images/districts/tokyo.png
Binary files differ
diff --git a/garden/src/index.css b/garden/src/index.css
index fe53bac..d1b4530 100644
--- a/garden/src/index.css
+++ b/garden/src/index.css
@@ -3,4 +3,5 @@
@import "./styles/council.css";
@import "./styles/modal.css";
@import "./styles/editor.css";
-@import "./styles/datepicker.css"; \ No newline at end of file
+@import "./styles/datepicker.css";
+@import "./styles/districts.css"; \ No newline at end of file
diff --git a/garden/src/pages/council/districts.tsx b/garden/src/pages/council/districts.tsx
new file mode 100644
index 0000000..eac6329
--- /dev/null
+++ b/garden/src/pages/council/districts.tsx
@@ -0,0 +1,350 @@
+import { createSignal, onMount, Show, For } from "solid-js";
+import { useSearchParams } from "@solidjs/router";
+import { api } from "../../api";
+import { auth } from "../../store/auth";
+import { extractError } from "../../utils/api";
+import { useClickOutside } from "../../utils/clickOutside";
+import type { SiteRequest, AdminSite } from "../../types/district";
+import type { PaginatedResponse } from "../../types/admin";
+import Pagination from "../../components/Pagination";
+import StaffGuard from "../../components/StaffGuard";
+import Modal from "../../components/Modal";
+
+const STATUS_LABELS: Record<string, string> = {
+ "": "Pending & Hold",
+ pending: "Pending",
+ hold: "On Hold",
+ approved: "Approved",
+ denied: "Denied",
+};
+
+export default function CouncilDistricts() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [tab, setTab] = createSignal<"requests" | "sites">("requests");
+
+ const [requests, setRequests] = createSignal<SiteRequest[]>([]);
+ const [reqTotal, setReqTotal] = createSignal(0);
+ const [reqPage, setReqPage] = createSignal(1);
+ const [reqTotalPages, setReqTotalPages] = createSignal(0);
+ const [reqLoading, setReqLoading] = createSignal(false);
+ const [reqStatus, setReqStatus] = createSignal("");
+ const [statusOpen, setStatusOpen] = createSignal(false);
+ let statusRef: HTMLDivElement | undefined;
+ useClickOutside(() => statusRef, setStatusOpen);
+
+ const [adminSites, setAdminSites] = createSignal<AdminSite[]>([]);
+ const [siteTotal, setSiteTotal] = createSignal(0);
+ const [sitePage, setSitePage] = createSignal(1);
+ const [siteTotalPages, setSiteTotalPages] = createSignal(0);
+ const [siteLoading, setSiteLoading] = createSignal(false);
+ const [siteSearch, setSiteSearch] = createSignal("");
+
+ const [reviewTarget, setReviewTarget] = createSignal<SiteRequest | null>(null);
+ const [reviewAction, setReviewAction] = createSignal("");
+ const [reviewError, setReviewError] = createSignal("");
+ const [reviewing, setReviewing] = createSignal(false);
+
+ const [editTarget, setEditTarget] = createSignal<AdminSite | null>(null);
+ const [editTitle, setEditTitle] = createSignal("");
+ const [editDescription, setEditDescription] = createSignal("");
+ const [editError, setEditError] = createSignal("");
+ const [editing, setEditing] = createSignal(false);
+
+ onMount(() => {
+ const initialTab = searchParams.tab as string;
+ if (initialTab === "sites") setTab("sites");
+ loadRequests();
+ });
+
+ async function loadRequests(pageNumber = 1) {
+ setReqLoading(true);
+ const queryParams = new URLSearchParams({ page: String(pageNumber), per_page: "20" });
+ if (reqStatus()) queryParams.set("status", reqStatus());
+
+ const response = await api<PaginatedResponse<SiteRequest>>(`/council/districts/requests?${queryParams}`, {
+ token: auth.token(),
+ });
+
+ if (response.ok) {
+ setRequests(response.data.items);
+ setReqTotal(response.data.total);
+ setReqPage(response.data.page);
+ setReqTotalPages(response.data.total_pages);
+ }
+ setReqLoading(false);
+ }
+
+ async function loadSites(pageNumber = 1) {
+ setSiteLoading(true);
+ const queryParams = new URLSearchParams({ page: String(pageNumber), per_page: "20" });
+ if (siteSearch()) queryParams.set("search", siteSearch());
+
+ const response = await api<PaginatedResponse<AdminSite>>(`/council/districts/sites?${queryParams}`, {
+ token: auth.token(),
+ });
+
+ if (response.ok) {
+ setAdminSites(response.data.items);
+ setSiteTotal(response.data.total);
+ setSitePage(response.data.page);
+ setSiteTotalPages(response.data.total_pages);
+ }
+ setSiteLoading(false);
+ }
+
+ function switchTab(selectedTab: "requests" | "sites") {
+ setTab(selectedTab);
+ setSearchParams({ tab: selectedTab });
+ if (selectedTab === "requests") loadRequests();
+ else loadSites();
+ }
+
+ function pickStatus(value: string) {
+ setReqStatus(value);
+ setStatusOpen(false);
+ loadRequests(1);
+ }
+
+ function openReview(site: SiteRequest, action: string) {
+ setReviewTarget(site);
+ setReviewAction(action);
+ setReviewError("");
+ }
+
+ async function submitReview() {
+ const target = reviewTarget();
+ if (!target) return;
+ setReviewing(true);
+ setReviewError("");
+
+ const response = await api<SiteRequest>(`/council/districts/sites/${target.ref}/review`, {
+ method: "POST",
+ token: auth.token(),
+ body: { status: reviewAction() },
+ });
+
+ if (response.ok) {
+ setReviewTarget(null);
+ loadRequests(reqPage());
+ } else {
+ setReviewError(extractError(response.data));
+ }
+ setReviewing(false);
+ }
+
+ function openEdit(site: AdminSite) {
+ setEditTarget(site);
+ setEditTitle(site.title);
+ setEditDescription(site.description);
+ setEditError("");
+ }
+
+ async function submitEdit() {
+ const target = editTarget();
+ if (!target) return;
+ setEditing(true);
+ setEditError("");
+
+ const response = await api<AdminSite>(`/council/districts/sites/${target.ref}`, {
+ method: "PATCH",
+ token: auth.token(),
+ body: {
+ title: editTitle(),
+ description: editDescription(),
+ },
+ });
+
+ if (response.ok) {
+ setEditTarget(null);
+ loadSites(sitePage());
+ } else {
+ setEditError(extractError(response.data));
+ }
+ setEditing(false);
+ }
+
+ function statusLabel(status: string) {
+ return STATUS_LABELS[status] || status;
+ }
+
+ return (
+ <StaffGuard>
+ <section>
+ <h2 class="page-title">Districts</h2>
+
+ <div class="council-tabs">
+ <button
+ type="button"
+ class={`council-tab ${tab() === "requests" ? "active" : ""}`}
+ onClick={() => switchTab("requests")}
+ >
+ Requests
+ </button>
+ <button
+ type="button"
+ class={`council-tab ${tab() === "sites" ? "active" : ""}`}
+ onClick={() => switchTab("sites")}
+ >
+ Sites
+ </button>
+ </div>
+
+ <Show when={tab() === "requests"}>
+ <div class="council-audit-filters">
+ <div class="council-audit-dropdown" ref={statusRef}>
+ <button type="button" class="council-audit-dropdown-trigger" onClick={() => setStatusOpen(!statusOpen())}>
+ {statusLabel(reqStatus())}
+ </button>
+ <Show when={statusOpen()}>
+ <div class="council-audit-dropdown-menu">
+ <For each={Object.entries(STATUS_LABELS)}>
+ {([key, label]: [string, string]) => (
+ <button
+ type="button"
+ class="council-audit-dropdown-item"
+ classList={{ "council-audit-dropdown-item-selected": reqStatus() === key }}
+ onClick={() => pickStatus(key)}
+ >
+ {label}
+ </button>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
+ <Show when={reqStatus()}>
+ <button type="button" class="council-audit-clear-btn" onClick={() => pickStatus("")}>Clear</button>
+ </Show>
+ </div>
+
+ <div class="council-grid district-req-grid">
+ <div class="council-grid-header">
+ <span>Title</span>
+ <span>District</span>
+ <span>Submitter</span>
+ <span>Status</span>
+ <span>Actions</span>
+ </div>
+ <Show when={!reqLoading()} fallback={
+ <div class="council-grid-empty">Loading...</div>
+ }>
+ <Show when={requests().length} fallback={
+ <div class="council-grid-empty">No requests found.</div>
+ }>
+ <For each={requests()}>
+ {(request) => (
+ <div class="council-grid-row">
+ <span>
+ <a href={request.url} target="_blank" rel="noopener noreferrer">{request.title}</a>
+ </span>
+ <span>{request.district}</span>
+ <span>{request.submitter.display_name}</span>
+ <span class={`status-badge status-${request.status}`}>{statusLabel(request.status)}</span>
+ <span class="council-actions">
+ <Show when={request.status === "pending" || request.status === "hold"}>
+ <button type="button" class="action-btn approve" onClick={() => openReview(request, "approved")}>Approve</button>
+ <button type="button" class="action-btn deny" onClick={() => openReview(request, "denied")}>Deny</button>
+ <Show when={request.status === "pending"}>
+ <button type="button" class="action-btn hold" onClick={() => openReview(request, "hold")}>Hold</button>
+ </Show>
+ </Show>
+ </span>
+ </div>
+ )}
+ </For>
+ </Show>
+ </Show>
+ </div>
+
+ <Pagination page={reqPage()} totalPages={reqTotalPages()} total={reqTotal()} label="requests" onPage={(pageNumber) => loadRequests(pageNumber)} />
+ </Show>
+
+ <Show when={tab() === "sites"}>
+ <div class="council-search">
+ <input
+ type="text"
+ placeholder="Search sites..."
+ value={siteSearch()}
+ onInput={(e) => setSiteSearch(e.currentTarget.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") loadSites(1); }}
+ />
+ <button type="button" class="form-button" onClick={() => loadSites(1)}>Search</button>
+ </div>
+
+ <div class="council-grid district-site-grid">
+ <div class="council-grid-header">
+ <span>Title</span>
+ <span>District</span>
+ <span>URL</span>
+ <span>Actions</span>
+ </div>
+ <Show when={!siteLoading()} fallback={
+ <div class="council-grid-empty">Loading...</div>
+ }>
+ <Show when={adminSites().length} fallback={
+ <div class="council-grid-empty">No approved sites found.</div>
+ }>
+ <For each={adminSites()}>
+ {(site) => (
+ <div class="council-grid-row">
+ <span>{site.title}</span>
+ <span>{site.district}</span>
+ <span>
+ <a href={site.url} target="_blank" rel="noopener noreferrer">{site.url}</a>
+ </span>
+ <span>
+ <button type="button" class="action-btn" onClick={() => openEdit(site)}>Edit</button>
+ </span>
+ </div>
+ )}
+ </For>
+ </Show>
+ </Show>
+ </div>
+
+ <Pagination page={sitePage()} totalPages={siteTotalPages()} total={siteTotal()} label="sites" onPage={(pageNumber) => loadSites(pageNumber)} />
+ </Show>
+
+ <Show when={reviewTarget()}>
+ <Modal title={`${reviewAction() === "approved" ? "Approve" : reviewAction() === "denied" ? "Deny" : "Hold"} Site`} onClose={() => setReviewTarget(null)}>
+ <p>
+ Are you sure you want to {reviewAction() === "approved" ? "approve" : reviewAction() === "denied" ? "deny" : "put on hold"}{" "}
+ <strong>{reviewTarget()!.title}</strong>?
+ </p>
+ <Show when={reviewError()}>
+ <div class="form-error">{reviewError()}</div>
+ </Show>
+ <div class="modal-actions">
+ <button type="button" class="form-button" onClick={submitReview} disabled={reviewing()}>
+ {reviewing() ? "Processing..." : "Confirm"}
+ </button>
+ <button type="button" class="form-button secondary" onClick={() => setReviewTarget(null)}>Cancel</button>
+ </div>
+ </Modal>
+ </Show>
+
+ <Show when={editTarget()}>
+ <Modal title="Edit Site" onClose={() => setEditTarget(null)}>
+ <Show when={editError()}>
+ <div class="form-error">{editError()}</div>
+ </Show>
+ <div class="form-field">
+ <label>Title</label>
+ <input type="text" value={editTitle()} onInput={(e) => setEditTitle(e.currentTarget.value)} maxLength={200} />
+ </div>
+ <div class="form-field">
+ <label>Description</label>
+ <textarea value={editDescription()} onInput={(e) => setEditDescription(e.currentTarget.value)} maxLength={1000} rows={3} />
+ </div>
+ <div class="modal-actions">
+ <button type="button" class="form-button" onClick={submitEdit} disabled={editing()}>
+ {editing() ? "Saving..." : "Save"}
+ </button>
+ <button type="button" class="form-button secondary" onClick={() => setEditTarget(null)}>Cancel</button>
+ </div>
+ </Modal>
+ </Show>
+ </section>
+ </StaffGuard>
+ );
+} \ No newline at end of file
diff --git a/garden/src/pages/districts/district.tsx b/garden/src/pages/districts/district.tsx
new file mode 100644
index 0000000..028bc46
--- /dev/null
+++ b/garden/src/pages/districts/district.tsx
@@ -0,0 +1,145 @@
+import { createSignal, onMount, Show, For } from "solid-js";
+import { useParams, useSearchParams, A } from "@solidjs/router";
+import { api } from "../../api";
+import type { District, DistrictSite } from "../../types/district";
+import type { PaginatedResponse } from "../../types/admin";
+import { districtImage, districtIconClass } from "../../utils/districts";
+import { formatDate } from "../../utils/format";
+import Pagination from "../../components/Pagination";
+
+export default function DistrictDetail() {
+ const params = useParams();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [district, setDistrict] = createSignal<District | null>(null);
+ const [sites, setSites] = createSignal<DistrictSite[]>([]);
+ const [total, setTotal] = createSignal(0);
+ const [page, setPage] = createSignal(1);
+ const [totalPages, setTotalPages] = createSignal(0);
+ const [loading, setLoading] = createSignal(true);
+ const [tagInput, setTagInput] = createSignal("");
+ const [searchInput, setSearchInput] = createSignal("");
+
+ onMount(async () => {
+ const districtResponse = await api<District[]>("/districts");
+ if (districtResponse.ok) {
+ const found = districtResponse.data.find((entry) => entry.slug === params.slug);
+ if (found) setDistrict(found);
+ }
+
+ const initialPage = parseInt(searchParams.page as string) || 1;
+ const initialTag = (searchParams.tag as string) || "";
+ const initialSearch = (searchParams.search as string) || "";
+ setTagInput(initialTag);
+ setSearchInput(initialSearch);
+ loadSites(initialPage, initialTag, initialSearch);
+ });
+
+ async function loadSites(pageNumber = 1, tag = "", search = "") {
+ setLoading(true);
+ const queryParams = new URLSearchParams({
+ page: String(pageNumber),
+ per_page: "20",
+ district: params.slug || "",
+ });
+ if (tag) queryParams.set("tag", tag);
+ if (search) queryParams.set("search", search);
+
+ const response = await api<PaginatedResponse<DistrictSite>>(`/districts/sites?${queryParams}`);
+ if (response.ok) {
+ setSites(response.data.items);
+ setTotal(response.data.total);
+ setPage(response.data.page);
+ setTotalPages(response.data.total_pages);
+ }
+ setLoading(false);
+ }
+
+ function handleFilter(event: Event) {
+ event.preventDefault();
+ setSearchParams({ page: "1", tag: tagInput(), search: searchInput() });
+ loadSites(1, tagInput(), searchInput());
+ }
+
+ function goToPage(pageNumber: number) {
+ setSearchParams({ page: String(pageNumber), tag: tagInput(), search: searchInput() });
+ loadSites(pageNumber, tagInput(), searchInput());
+ }
+
+ return (
+ <section>
+ <div class="district-header">
+ <Show when={district()}>
+ {(currentDistrict) => (
+ <>
+ <div class="district-header-info">
+ <A href="/districts" class="district-back">Districts</A>
+ <h1 class="page-title" style={{ color: currentDistrict().foreground }}>{currentDistrict().name}</h1>
+ <p class="district-description">{currentDistrict().description}</p>
+ </div>
+ <div class="district-header-icon">
+ <img src={districtImage(currentDistrict().slug)} alt={currentDistrict().name} class={districtIconClass(currentDistrict().slug)} />
+ </div>
+ </>
+ )}
+ </Show>
+ </div>
+
+ <form class="district-filters" onSubmit={handleFilter}>
+ <input
+ type="text"
+ placeholder="Search sites..."
+ value={searchInput()}
+ onInput={(e) => setSearchInput(e.currentTarget.value)}
+ />
+ <input
+ type="text"
+ placeholder="Filter by tag..."
+ value={tagInput()}
+ onInput={(e) => setTagInput(e.currentTarget.value)}
+ />
+ <button type="submit" class="form-button">Filter</button>
+ </form>
+
+ <Show when={!loading()} fallback={<p class="loading-text">Loading sites...</p>}>
+ <Show when={sites().length > 0} fallback={<p class="empty-text">No sites found in this district.</p>}>
+ <div class="site-grid">
+ <For each={sites()}>
+ {(site) => (
+ <a href={site.url} target="_blank" rel="noopener noreferrer" class="site-card">
+ <div class="site-card-thumbnail">
+ <Show when={site.thumbnail_url} fallback={<div class="site-card-placeholder" />}>
+ <img src={site.thumbnail_url} alt={site.title} />
+ </Show>
+ </div>
+ <div class="site-card-info">
+ <h3 class="site-card-title">{site.title}</h3>
+ <p class="site-card-url">{site.url}</p>
+ <Show when={site.tags.length > 0}>
+ <div class="site-card-tags">
+ <For each={site.tags}>
+ {(tag) => <span class="site-tag">{tag}</span>}
+ </For>
+ </div>
+ </Show>
+ <div class="site-card-meta">
+ <span>by {site.submitter.display_name}</span>
+ <span>{formatDate(site.created_at)}</span>
+ </div>
+ </div>
+ </a>
+ )}
+ </For>
+ </div>
+
+ <Pagination
+ page={page()}
+ totalPages={totalPages()}
+ total={total()}
+ label="sites"
+ onPage={goToPage}
+ />
+ </Show>
+ </Show>
+ </section>
+ );
+} \ No newline at end of file
diff --git a/garden/src/pages/districts/index.tsx b/garden/src/pages/districts/index.tsx
new file mode 100644
index 0000000..c2e3656
--- /dev/null
+++ b/garden/src/pages/districts/index.tsx
@@ -0,0 +1,57 @@
+import { createSignal, onMount, Show, For } from "solid-js";
+import { A } from "@solidjs/router";
+import { api } from "../../api";
+import { auth } from "../../store/auth";
+import type { District } from "../../types/district";
+import { districtImage, districtIconClass } from "../../utils/districts";
+
+export default function Districts() {
+ const [districts, setDistricts] = createSignal<District[]>([]);
+ const [loading, setLoading] = createSignal(true);
+
+ onMount(async () => {
+ const response = await api<District[]>("/districts");
+ if (response.ok) {
+ setDistricts(response.data);
+ }
+ setLoading(false);
+ });
+
+ return (
+ <section>
+ <h1 class="page-title">Districts</h1>
+ <p class="district-intro">
+ Districts are themed directories of websites from across the small web.
+ Browse sites by category, or <Show when={auth.user()} fallback={<span>log in to submit your own</span>}><A href="/districts/submit">submit your own</A></Show> to be listed.
+ </p>
+
+ <Show when={!loading()} fallback={<p class="loading-text">Loading districts...</p>}>
+ <div class="district-grid">
+ <For each={districts()}>
+ {(district) => (
+ <A
+ href={`/districts/${district.slug}`}
+ class="district-card"
+ style={{
+ "background-color": district.background,
+ "border-color": district.foreground,
+ }}
+ >
+ <div class="district-card-info">
+ <h2 class="district-card-name" style={{ color: district.foreground }}>{district.name}</h2>
+ <p class="district-card-desc" style={{ color: district.detail }}>{district.description}</p>
+ <span class="district-card-count" style={{ color: district.detail }}>
+ {district.site_count} {district.site_count === 1 ? "site" : "sites"}
+ </span>
+ </div>
+ <div class="district-card-icon">
+ <img src={districtImage(district.slug)} alt={district.name} class={districtIconClass(district.slug)} />
+ </div>
+ </A>
+ )}
+ </For>
+ </div>
+ </Show>
+ </section>
+ );
+} \ No newline at end of file
diff --git a/garden/src/pages/districts/submit.tsx b/garden/src/pages/districts/submit.tsx
new file mode 100644
index 0000000..af58e22
--- /dev/null
+++ b/garden/src/pages/districts/submit.tsx
@@ -0,0 +1,171 @@
+import { createSignal, onMount, Show, For } from "solid-js";
+import { useNavigate } from "@solidjs/router";
+import { api } from "../../api";
+import { auth } from "../../store/auth";
+import { extractError } from "../../utils/api";
+import type { District, SiteRequest } from "../../types/district";
+import { districtImage, districtIconClass } from "../../utils/districts";
+
+export default function SubmitSite() {
+ const navigate = useNavigate();
+ const [districts, setDistricts] = createSignal<District[]>([]);
+ const [selectedDistrict, setSelectedDistrict] = createSignal("");
+ const [title, setTitle] = createSignal("");
+ const [url, setUrl] = createSignal("");
+ const [description, setDescription] = createSignal("");
+ const [tagInput, setTagInput] = createSignal("");
+ const [tags, setTags] = createSignal<string[]>([]);
+ const [error, setError] = createSignal("");
+ const [submitting, setSubmitting] = createSignal(false);
+
+ onMount(async () => {
+ const response = await api<District[]>("/districts");
+ if (response.ok) {
+ setDistricts(response.data);
+ }
+ });
+
+ function addTag() {
+ const tag = tagInput().trim().toLowerCase();
+ if (tag && tags().length < 5 && !tags().includes(tag)) {
+ setTags([...tags(), tag]);
+ setTagInput("");
+ }
+ }
+
+ function removeTag(tag: string) {
+ setTags(tags().filter((existingTag) => existingTag !== tag));
+ }
+
+ function handleTagKeyDown(event: KeyboardEvent) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ addTag();
+ }
+ }
+
+ async function handleSubmit(event: Event) {
+ event.preventDefault();
+ setError("");
+ setSubmitting(true);
+
+ const response = await api<SiteRequest>("/districts/sites", {
+ method: "POST",
+ token: auth.token(),
+ body: {
+ district: selectedDistrict(),
+ title: title(),
+ url: url(),
+ description: description(),
+ tags: tags(),
+ },
+ });
+
+ if (response.ok) {
+ navigate("/districts");
+ } else {
+ setError(extractError(response.data));
+ }
+ setSubmitting(false);
+ }
+
+ return (
+ <section>
+ <h1 class="page-title">Submit a Site</h1>
+ <p class="district-intro">
+ Submit a website to be listed in a district. Your submission will be reviewed by staff before it appears.
+ </p>
+
+ <Show when={error()}>
+ <div class="form-error">{error()}</div>
+ </Show>
+
+ <form class="submit-site-form" onSubmit={handleSubmit}>
+ <div class="form-field">
+ <label>District</label>
+ <div class="district-select-grid">
+ <For each={districts()}>
+ {(district) => (
+ <button
+ type="button"
+ class={`district-select-option ${selectedDistrict() === district.slug ? "selected" : ""}`}
+ style={{
+ "border-color": selectedDistrict() === district.slug ? district.foreground : district.background,
+ "background-color": district.background,
+ }}
+ onClick={() => setSelectedDistrict(district.slug)}
+ >
+ <img src={districtImage(district.slug)} alt={district.name} class={`district-select-icon ${districtIconClass(district.slug)}`} />
+ <span style={{ color: district.foreground }}>{district.name}</span>
+ </button>
+ )}
+ </For>
+ </div>
+ </div>
+
+ <div class="form-field">
+ <label>Site Title</label>
+ <input
+ type="text"
+ value={title()}
+ onInput={(e) => setTitle(e.currentTarget.value)}
+ placeholder="My Cool Website"
+ maxLength={200}
+ />
+ </div>
+
+ <div class="form-field">
+ <label>URL</label>
+ <input
+ type="url"
+ value={url()}
+ onInput={(e) => setUrl(e.currentTarget.value)}
+ placeholder="https://example.nekoweb.org"
+ />
+ </div>
+
+ <div class="form-field">
+ <label>Description</label>
+ <textarea
+ value={description()}
+ onInput={(e) => setDescription(e.currentTarget.value)}
+ placeholder="A short description of the site..."
+ maxLength={1000}
+ rows={3}
+ />
+ </div>
+
+ <div class="form-field">
+ <label>Tags (up to 5)</label>
+ <div class="tag-input-row">
+ <input
+ type="text"
+ value={tagInput()}
+ onInput={(e) => setTagInput(e.currentTarget.value)}
+ onKeyDown={handleTagKeyDown}
+ placeholder="Add a tag..."
+ maxLength={50}
+ disabled={tags().length >= 5}
+ />
+ <button type="button" class="form-button" onClick={addTag} disabled={tags().length >= 5}>Add</button>
+ </div>
+ <Show when={tags().length > 0}>
+ <div class="tag-list">
+ <For each={tags()}>
+ {(tag) => (
+ <span class="site-tag removable" onClick={() => removeTag(tag)}>
+ {tag} &times;
+ </span>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
+
+ <button type="submit" class="form-button" disabled={submitting() || !selectedDistrict()}>
+ {submitting() ? "Submitting..." : "Submit Site"}
+ </button>
+ </form>
+ </section>
+ );
+} \ No newline at end of file
diff --git a/garden/src/routes.ts b/garden/src/routes.ts
index 41746a5..0ad9d5c 100644
--- a/garden/src/routes.ts
+++ b/garden/src/routes.ts
@@ -14,5 +14,9 @@ export const routes: RouteDefinition[] = [
{ path: "/council/bannedips", component: lazy(() => import("./pages/council/bannedips")) },
{ path: "/council/auditlog", component: lazy(() => import("./pages/council/auditlog")) },
{ path: "/council/auditlog/:ref", component: lazy(() => import("./pages/council/auditdetail")) },
+ { path: "/council/districts", component: lazy(() => import("./pages/council/districts")) },
+ { path: "/districts", component: lazy(() => import("./pages/districts/index")) },
+ { path: "/districts/submit", component: lazy(() => import("./pages/districts/submit")) },
+ { path: "/districts/:slug", component: lazy(() => import("./pages/districts/district")) },
{ path: "**", component: lazy(() => import("./errors/404")) },
];
diff --git a/garden/src/styles/districts.css b/garden/src/styles/districts.css
new file mode 100644
index 0000000..fae7f2e
--- /dev/null
+++ b/garden/src/styles/districts.css
@@ -0,0 +1,416 @@
+.district-intro {
+ font-size: 13px;
+ color: var(--color-text-muted);
+ margin-bottom: 16px;
+}
+
+.district-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.district-card {
+ display: flex;
+ align-items: center;
+ border: 1px solid;
+ padding: 14px 16px;
+ text-decoration: none;
+ color: var(--color-text);
+ transition: filter 0.15s;
+}
+
+.district-card:hover {
+ filter: brightness(1.15);
+}
+
+.district-card-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.district-card-name {
+ font-family: var(--font-display);
+ font-size: 20px;
+ font-weight: 700;
+ margin: 0;
+}
+
+.district-card-desc {
+ font-size: 12px;
+ margin: 2px 0 4px;
+}
+
+.district-card-count {
+ font-size: 11px;
+ opacity: 0.7;
+}
+
+.district-card-icon {
+ flex-shrink: 0;
+ width: 120px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.district-card-icon img {
+ width: 100px;
+ height: auto;
+ image-rendering: pixelated;
+}
+
+.icon-outlined {
+ filter:
+ drop-shadow(1px 0 0 rgba(255, 255, 255, 0.4))
+ drop-shadow(-1px 0 0 rgba(255, 255, 255, 0.4))
+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.4))
+ drop-shadow(0 -1px 0 rgba(255, 255, 255, 0.4));
+}
+
+.district-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.district-header-info {
+ flex: 1;
+}
+
+.district-back {
+ font-size: 12px;
+ color: var(--color-text-muted);
+ text-decoration: none;
+}
+
+.district-back:hover {
+ color: var(--color-text);
+}
+
+.district-description {
+ font-size: 13px;
+ color: var(--color-text-muted);
+}
+
+.district-header-icon {
+ flex-shrink: 0;
+ width: 140px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.district-header-icon img {
+ width: 125px;
+ height: auto;
+ image-rendering: pixelated;
+ filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.35)) drop-shadow(0 0 1px rgba(255, 255, 255, 0.5));
+}
+
+.district-filters {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 16px;
+ align-items: stretch;
+}
+
+.district-filters input {
+ flex: 1;
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ padding: 6px 8px;
+ font-family: var(--font-body);
+ font-size: 13px;
+ color: var(--color-text);
+ outline: none;
+}
+
+.district-filters input:focus {
+ border-color: var(--color-purple);
+}
+
+.district-filters .form-button {
+ padding: 0 12px;
+}
+
+.site-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.site-card {
+ display: flex;
+ flex-direction: column;
+ background: var(--color-panel);
+ border: 1px solid var(--color-border);
+ text-decoration: none;
+ color: var(--color-text);
+ overflow: hidden;
+ transition: border-color 0.15s;
+}
+
+.site-card:hover {
+ border-color: var(--color-purple);
+}
+
+.site-card-thumbnail {
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ overflow: hidden;
+ background: var(--color-bg);
+}
+
+.site-card-thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.site-card-placeholder {
+ width: 100%;
+ height: 100%;
+ background: var(--color-panel-header);
+}
+
+.site-card-info {
+ padding: 8px 10px;
+}
+
+.site-card-title {
+ font-family: var(--font-display);
+ font-size: 14px;
+ font-weight: 700;
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.site-card-url {
+ font-size: 10px;
+ color: var(--color-text-muted);
+ margin: 2px 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.site-card-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 4px;
+}
+
+.site-tag {
+ font-size: 10px;
+ background: var(--color-panel-header);
+ border: 1px solid var(--color-border);
+ padding: 1px 6px;
+ color: var(--color-text-muted);
+}
+
+.site-tag.removable {
+ cursor: pointer;
+}
+
+.site-tag.removable:hover {
+ border-color: var(--color-red);
+ color: var(--color-red);
+}
+
+.site-card-meta {
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ color: var(--color-text-muted);
+ margin-top: 6px;
+}
+
+.submit-site-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ max-width: 600px;
+}
+
+.submit-site-form textarea {
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ padding: 6px 8px;
+ font-family: var(--font-body);
+ font-size: 13px;
+ color: var(--color-text);
+ outline: none;
+ resize: vertical;
+}
+
+.submit-site-form textarea:focus {
+ border-color: var(--color-purple);
+}
+
+.submit-site-form .form-button {
+ align-self: flex-start;
+}
+
+.district-select-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+}
+
+.district-select-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border: 2px solid;
+ padding: 8px 12px;
+ cursor: pointer;
+ font-family: var(--font-display);
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--color-text);
+ text-align: left;
+ transition: filter 0.15s;
+}
+
+.district-select-option:hover {
+ filter: brightness(1.15);
+}
+
+.district-select-icon {
+ width: 44px;
+ height: auto;
+ image-rendering: pixelated;
+}
+
+.tag-input-row {
+ display: flex;
+ gap: 6px;
+ align-items: stretch;
+}
+
+.tag-input-row input {
+ flex: 1;
+}
+
+.tag-input-row .form-button {
+ padding: 6px 12px;
+}
+
+.tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+}
+
+.council-tabs {
+ display: flex;
+ gap: 0;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.council-tab {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ padding: 8px 16px;
+ font-family: var(--font-display);
+ font-size: 14px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--color-text-muted);
+ cursor: pointer;
+}
+
+.council-tab:hover {
+ color: var(--color-text);
+}
+
+.council-tab.active {
+ color: var(--color-red);
+ border-bottom-color: var(--color-red);
+}
+
+.district-req-grid .council-grid-header,
+.district-req-grid .council-grid-row {
+ grid-template-columns: 3fr 2fr 2fr 1.5fr 2fr;
+}
+
+.district-site-grid .council-grid-header,
+.district-site-grid .council-grid-row {
+ grid-template-columns: 3fr 2fr 3fr 1.5fr;
+}
+
+.council-actions {
+ display: flex;
+ gap: 4px;
+}
+
+.action-btn {
+ background: var(--color-panel-header);
+ border: 1px solid var(--color-border);
+ padding: 2px 8px;
+ font-family: var(--font-body);
+ font-size: 11px;
+ color: var(--color-text);
+ cursor: pointer;
+}
+
+.action-btn:hover {
+ background: var(--color-bg);
+}
+
+.action-btn.approve {
+ border-color: var(--color-green);
+ color: var(--color-green);
+}
+
+.action-btn.deny {
+ border-color: var(--color-red);
+ color: var(--color-red);
+}
+
+.action-btn.hold {
+ border-color: var(--color-yellow);
+ color: var(--color-yellow);
+}
+
+.status-badge {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.status-pending { color: var(--color-yellow); }
+.status-approved { color: var(--color-green); }
+.status-denied { color: var(--color-red); }
+.status-hold { color: var(--color-cyan); }
+
+.modal-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.form-button.secondary {
+ background: var(--color-panel-header);
+}
+
+.form-button.secondary:hover {
+ background: var(--color-border);
+}
+
+.loading-text,
+.empty-text {
+ font-size: 13px;
+ color: var(--color-text-muted);
+ padding: 16px 0;
+} \ No newline at end of file
diff --git a/garden/src/styles/layout.css b/garden/src/styles/layout.css
index bad6ae1..7a15fd2 100644
--- a/garden/src/styles/layout.css
+++ b/garden/src/styles/layout.css
@@ -7,7 +7,7 @@
body {
font-family: var(--font-body);
background-color: var(--color-bg);
- background-image: url("/images/background.webp");
+ background-image: url("/images/backgrounds/background.webp");
background-repeat: repeat;
color: var(--color-text);
min-width: var(--width-container);
@@ -466,7 +466,7 @@ a:hover {
.form-button {
background: var(--color-purple);
border: none;
- padding: 8px 16px;
+ padding: 6px 16px;
font-family: var(--font-display);
font-size: 13px;
font-weight: 600;
@@ -474,7 +474,6 @@ a:hover {
letter-spacing: 2px;
color: var(--color-white);
cursor: pointer;
- margin-top: 4px;
}
.form-button:hover {
diff --git a/garden/src/types/admin.ts b/garden/src/types/admin.ts
index 036c6e4..3141c7a 100644
--- a/garden/src/types/admin.ts
+++ b/garden/src/types/admin.ts
@@ -58,9 +58,12 @@ export const AUDIT_ACTION_LABELS: Record<string, string> = {
"user.warn": "Warn User",
"user.unwarn": "Deactivate Warning",
"ticket.update": "Update Ticket",
+ "district.review": "Review Site",
+ "district.edit": "Edit Site",
};
export const AUDIT_TARGET_LABELS: Record<string, string> = {
user: "User",
ticket: "Ticket",
+ site: "Site",
}; \ No newline at end of file
diff --git a/garden/src/types/district.ts b/garden/src/types/district.ts
new file mode 100644
index 0000000..e381cdf
--- /dev/null
+++ b/garden/src/types/district.ts
@@ -0,0 +1,40 @@
+export interface District {
+ slug: string;
+ name: string;
+ description: string;
+ background: string;
+ foreground: string;
+ detail: string;
+ site_count: number;
+}
+
+export interface CitizenSummary {
+ username: string;
+ display_name: string;
+ avatar_url: string;
+}
+
+export interface DistrictSite {
+ ref: string;
+ district: string;
+ district_slug: string;
+ title: string;
+ url: string;
+ description: string;
+ thumbnail_url: string;
+ tags: string[];
+ submitter: CitizenSummary;
+ created_at: string;
+}
+
+export interface SiteRequest extends DistrictSite {
+ status: string;
+ reviewed_by: CitizenSummary | null;
+ reviewed_at: string | null;
+}
+
+export interface AdminSite extends DistrictSite {
+ status: string;
+ reviewed_by: CitizenSummary | null;
+ reviewed_at: string | null;
+} \ No newline at end of file
diff --git a/garden/src/utils/districts.ts b/garden/src/utils/districts.ts
new file mode 100644
index 0000000..9f2fb57
--- /dev/null
+++ b/garden/src/utils/districts.ts
@@ -0,0 +1,30 @@
+const DISTRICT_IMAGES: Record<string, string> = {
+ arcadia: "/images/districts/arcadia.png",
+ arles: "/images/districts/arles.png",
+ hollywood: "/images/districts/hollywood.png",
+ oxford: "/images/districts/oxford.png",
+ petsburg: "/images/districts/petsburg.png",
+ purgatory: "/images/districts/purgatory.png",
+ "silicon-valley": "/images/districts/silicon.png",
+ "silver-lake": "/images/districts/silver.png",
+ "stratford-upon-avon": "/images/districts/stratford.png",
+ tokyo: "/images/districts/tokyo.png",
+};
+
+export function districtImage(slug: string): string {
+ return DISTRICT_IMAGES[slug] || "";
+}
+
+const OUTLINED_SLUGS = new Set([
+ "arcadia",
+ "arles",
+ "silver-lake",
+ "silicon-valley",
+ "stratford-upon-avon",
+ "purgatory",
+ "tokyo",
+]);
+
+export function districtIconClass(slug: string): string {
+ return OUTLINED_SLUGS.has(slug) ? "icon-outlined" : "";
+}
diff --git a/shrine/controllers/district.go b/shrine/controllers/district.go
new file mode 100644
index 0000000..0f0d899
--- /dev/null
+++ b/shrine/controllers/district.go
@@ -0,0 +1,98 @@
+package controllers
+
+import (
+ "shrine/services"
+ "shrine/types/district"
+ "shrine/utils/auth"
+ "shrine/utils/meta"
+ "shrine/utils/shortcuts"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func ListDistrictsController(context *fiber.Ctx) error {
+ return shortcuts.Success(context, services.ListDistricts())
+}
+
+func ListDistrictSitesController(context *fiber.Ctx) error {
+ pagination := meta.Paginate(context)
+ request := meta.Request(context)
+ slug, _ := request.Query("district")
+ tag, _ := request.Query("tag")
+ search, _ := request.Query("search")
+
+ items, total := services.ListDistrictSites(pagination, slug, tag, search)
+ return shortcuts.Success(context, pagination.Response(items, total))
+}
+
+func SubmitSiteController(context *fiber.Ctx) error {
+ citizen := auth.GetUser(context)
+
+ body, err := meta.Body[district.SubmitSiteRequest](context)
+ if err != nil {
+ return shortcuts.BadRequest(context, err)
+ }
+
+ result, serviceErr := services.SubmitSite(citizen.ID, body)
+ if serviceErr != nil {
+ return shortcuts.HandleError(context, serviceErr)
+ }
+
+ return shortcuts.Created(context, result)
+}
+
+func ListSiteRequestsController(context *fiber.Ctx) error {
+ pagination := meta.Paginate(context)
+ status, _ := meta.Request(context).Query("status")
+
+ items, total := services.ListSiteRequests(pagination, status)
+ return shortcuts.Success(context, pagination.Response(items, total))
+}
+
+func ReviewSiteController(context *fiber.Ctx) error {
+ admin := auth.GetUser(context)
+ ref := meta.Request(context).MustHave().Param("ref")
+
+ body, err := meta.Body[district.ReviewSiteRequest](context)
+ if err != nil {
+ return shortcuts.BadRequest(context, err)
+ }
+
+ result, serviceErr := services.ReviewSite(admin.ID, ref, body)
+ if serviceErr != nil {
+ return shortcuts.HandleError(context, serviceErr)
+ }
+
+ return shortcuts.Success(context, result)
+}
+
+func ListAdminSitesController(context *fiber.Ctx) error {
+ pagination := meta.Paginate(context)
+ request := meta.Request(context)
+ slug, _ := request.Query("district")
+ search, _ := request.Query("search")
+
+ items, total := services.ListAdminSites(pagination, slug, search)
+ return shortcuts.Success(context, pagination.Response(items, total))
+}
+
+func EditSiteController(context *fiber.Ctx) error {
+ admin := auth.GetUser(context)
+ ref := meta.Request(context).MustHave().Param("ref")
+
+ body, err := meta.Body[district.EditSiteRequest](context)
+ if err != nil {
+ return shortcuts.BadRequest(context, err)
+ }
+
+ result, serviceErr := services.EditSite(admin.ID, ref, body)
+ if serviceErr != nil {
+ return shortcuts.HandleError(context, serviceErr)
+ }
+
+ return shortcuts.Success(context, result)
+}
+
+func CountPendingSitesController(context *fiber.Ctx) error {
+ return shortcuts.Success(context, fiber.Map{"count": services.CountPendingSites()})
+} \ No newline at end of file
diff --git a/shrine/database/migrate.go b/shrine/database/migrate.go
index b7ab59d..25f24f6 100644
--- a/shrine/database/migrate.go
+++ b/shrine/database/migrate.go
@@ -19,6 +19,8 @@ func migrate() {
&models.Ticket{},
&models.TicketMessage{},
&models.IPBan{},
+ &models.DistrictSite{},
+ &models.DistrictTag{},
)
if err != nil {
logger.Fatalf("Database", "Error during database migration: %v", err)
diff --git a/shrine/enums/district.go b/shrine/enums/district.go
new file mode 100644
index 0000000..f9efd6d
--- /dev/null
+++ b/shrine/enums/district.go
@@ -0,0 +1,25 @@
+package enums
+
+type DistrictSlug string
+
+const (
+ Arcadia DistrictSlug = "arcadia"
+ Arles DistrictSlug = "arles"
+ Hollywood DistrictSlug = "hollywood"
+ Oxford DistrictSlug = "oxford"
+ Petsburg DistrictSlug = "petsburg"
+ Purgatory DistrictSlug = "purgatory"
+ SiliconValley DistrictSlug = "silicon-valley"
+ SilverLake DistrictSlug = "silver-lake"
+ StratfordUponAvon DistrictSlug = "stratford-upon-avon"
+ Tokyo DistrictSlug = "tokyo"
+)
+
+type SiteStatus string
+
+const (
+ SitePending SiteStatus = "pending"
+ SiteApproved SiteStatus = "approved"
+ SiteDenied SiteStatus = "denied"
+ SiteHold SiteStatus = "hold"
+) \ No newline at end of file
diff --git a/shrine/go.mod b/shrine/go.mod
index 574a3b3..47861c0 100644
--- a/shrine/go.mod
+++ b/shrine/go.mod
@@ -18,8 +18,15 @@ require (
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
+ github.com/chromedp/chromedp v0.14.2 // indirect
+ github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -39,6 +46,7 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
@@ -47,8 +55,9 @@ require (
github.com/valyala/tcplisten v1.0.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
- golang.org/x/text v0.32.0 // indirect
+ golang.org/x/text v0.34.0 // indirect
)
diff --git a/shrine/go.sum b/shrine/go.sum
index 61ab6c4..4e9e7ae 100644
--- a/shrine/go.sum
+++ b/shrine/go.sum
@@ -2,6 +2,12 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -11,6 +17,14 @@ github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprM
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -65,6 +79,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
@@ -92,6 +108,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
+golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -102,6 +120,8 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/shrine/jobs/scheduler.go b/shrine/jobs/scheduler.go
new file mode 100644
index 0000000..9a404fe
--- /dev/null
+++ b/shrine/jobs/scheduler.go
@@ -0,0 +1,26 @@
+package jobs
+
+import (
+ "shrine/services"
+ "shrine/utils/logger"
+
+ "github.com/robfig/cron/v3"
+)
+
+var scheduler *cron.Cron
+
+func Start() {
+ scheduler = cron.New()
+
+ scheduler.AddFunc("0 4 * * *", services.GenerateThumbnails)
+
+ scheduler.Start()
+ logger.Successf("Jobs", "Scheduler started")
+}
+
+func Stop() {
+ if scheduler != nil {
+ scheduler.Stop()
+ logger.Infof("Jobs", "Scheduler stopped")
+ }
+} \ No newline at end of file
diff --git a/shrine/messages/district.go b/shrine/messages/district.go
new file mode 100644
index 0000000..3c27f4f
--- /dev/null
+++ b/shrine/messages/district.go
@@ -0,0 +1,21 @@
+package messages
+
+const (
+ InvalidDistrict = "Invalid district."
+ InvalidSiteStatus = "Invalid site status."
+ SiteNotFound = "Site not found."
+ SiteTitleRequired = "Title must be between 1 and 200 characters."
+ SiteURLRequired = "A valid URL is required."
+ SiteURLTaken = "This URL has already been submitted."
+ SiteDescriptionLong = "Description must be 1000 characters or fewer."
+ TooManyTags = "A site can have up to 5 tags."
+ TagTooLong = "Each tag must be 50 characters or fewer."
+ FailedSubmitSite = "Failed to submit site."
+ FailedReviewSite = "Failed to review site."
+ FailedEditSite = "Failed to update site."
+ SiteAlreadyReviewed = "This site has already been reviewed."
+ AuditApprovedSite = "Approved site %s"
+ AuditDeniedSite = "Denied site %s"
+ AuditHeldSite = "Put site %s on hold"
+ AuditEditedSite = "Edited site %s"
+) \ No newline at end of file
diff --git a/shrine/models/district.go b/shrine/models/district.go
new file mode 100644
index 0000000..eaaadd1
--- /dev/null
+++ b/shrine/models/district.go
@@ -0,0 +1,99 @@
+package models
+
+import (
+ "shrine/enums"
+ "shrine/types/district"
+ "shrine/utils/crypto"
+ "shrine/utils/districts"
+ "shrine/utils/storage"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type DistrictSite struct {
+ gorm.Model
+ Ref string `gorm:"size:12;uniqueIndex;not null"`
+ District string `gorm:"size:30;index;not null"`
+ SubmitterID uint `gorm:"index;not null"`
+ Submitter User `gorm:"foreignKey:SubmitterID"`
+ Title string `gorm:"size:200;not null"`
+ URL string `gorm:"size:500;uniqueIndex;not null"`
+ Description string `gorm:"size:1000"`
+ ThumbnailURL string `gorm:"size:500"`
+ Status string `gorm:"size:10;not null;default:pending;index"`
+ ReviewedByID *uint `gorm:"index"`
+ ReviewedBy *User `gorm:"foreignKey:ReviewedByID"`
+ ReviewedAt *time.Time
+ Tags []DistrictTag `gorm:"many2many:district_site_tags"`
+}
+
+type DistrictTag struct {
+ gorm.Model
+ Name string `gorm:"size:50;uniqueIndex;not null"`
+}
+
+func (self *DistrictSite) BeforeCreate(tx *gorm.DB) error {
+ if self.Ref == "" {
+ self.Ref = crypto.Ref()
+ }
+ return nil
+}
+
+func (self *DistrictSite) ToResponse() district.SiteResponse {
+ tagNames := make([]string, len(self.Tags))
+ for index, tag := range self.Tags {
+ tagNames[index] = tag.Name
+ }
+
+ districtInfo, _ := districts.Find(enums.DistrictSlug(self.District))
+
+ return district.SiteResponse{
+ Ref: self.Ref,
+ District: districtInfo.Name,
+ DistrictSlug: self.District,
+ Title: self.Title,
+ URL: self.URL,
+ Description: self.Description,
+ ThumbnailURL: storage.ResolveCDN(self.ThumbnailURL),
+ Tags: tagNames,
+ Submitter: self.Submitter.ToSummary(),
+ CreatedAt: self.CreatedAt,
+ }
+}
+
+func (self *DistrictSite) ToRequestResponse() district.SiteRequestResponse {
+ response := district.SiteRequestResponse{
+ SiteResponse: self.ToResponse(),
+ Status: self.Status,
+ }
+
+ if self.ReviewedBy != nil {
+ summary := self.ReviewedBy.ToSummary()
+ response.ReviewedBy = &summary
+ }
+
+ if self.ReviewedAt != nil {
+ response.ReviewedAt = self.ReviewedAt
+ }
+
+ return response
+}
+
+func (self *DistrictSite) ToAdminResponse() district.AdminSiteResponse {
+ response := district.AdminSiteResponse{
+ SiteResponse: self.ToResponse(),
+ Status: self.Status,
+ }
+
+ if self.ReviewedBy != nil {
+ summary := self.ReviewedBy.ToSummary()
+ response.ReviewedBy = &summary
+ }
+
+ if self.ReviewedAt != nil {
+ response.ReviewedAt = self.ReviewedAt
+ }
+
+ return response
+} \ No newline at end of file
diff --git a/shrine/repositories/district.go b/shrine/repositories/district.go
new file mode 100644
index 0000000..4772895
--- /dev/null
+++ b/shrine/repositories/district.go
@@ -0,0 +1,133 @@
+package repositories
+
+import (
+ "shrine/database"
+ "shrine/models"
+ "shrine/utils/meta"
+ "strings"
+)
+
+func CreateDistrictSite(site *models.DistrictSite) error {
+ return database.DB.Create(site).Error
+}
+
+func UpdateDistrictSite(site *models.DistrictSite) error {
+ return database.DB.Save(site).Error
+}
+
+func FindDistrictSiteByRef(ref string) (*models.DistrictSite, error) {
+ var site models.DistrictSite
+ err := database.DB.Preload("Submitter").Preload("ReviewedBy").Preload("Tags").Where("ref = ?", ref).First(&site).Error
+ return &site, err
+}
+
+func FindDistrictSiteByURL(url string) (*models.DistrictSite, error) {
+ var site models.DistrictSite
+ err := database.DB.Where("url = ?", url).First(&site).Error
+ return &site, err
+}
+
+func ListDistrictSites(pagination meta.Pagination, district string, tag string, search string) ([]models.DistrictSite, int64) {
+ var sites []models.DistrictSite
+ var total int64
+
+ query := database.DB.Model(&models.DistrictSite{}).Where("status = ?", "approved")
+ if district != "" {
+ query = query.Where("district = ?", district)
+ }
+ if tag != "" {
+ query = query.Where("id IN (SELECT district_site_id FROM district_site_tags INNER JOIN district_tags ON district_tags.id = district_site_tags.district_tag_id WHERE district_tags.name = ?)", strings.ToLower(tag))
+ }
+ if search != "" {
+ query = query.Where("title ILIKE ? OR description ILIKE ?", "%"+search+"%", "%"+search+"%")
+ }
+
+ query.Count(&total)
+ pagination.Apply(query.Order("created_at desc")).Preload("Submitter").Preload("Tags").Find(&sites)
+
+ return sites, total
+}
+
+func ListDistrictSiteRequests(pagination meta.Pagination, status string) ([]models.DistrictSite, int64) {
+ var sites []models.DistrictSite
+ var total int64
+
+ query := database.DB.Model(&models.DistrictSite{})
+ if status != "" {
+ query = query.Where("status = ?", status)
+ } else {
+ query = query.Where("status IN ?", []string{"pending", "hold"})
+ }
+
+ query.Count(&total)
+ pagination.Apply(query.Order("created_at asc")).Preload("Submitter").Preload("ReviewedBy").Preload("Tags").Find(&sites)
+
+ return sites, total
+}
+
+func ListAllDistrictSites(pagination meta.Pagination, district string, search string) ([]models.DistrictSite, int64) {
+ var sites []models.DistrictSite
+ var total int64
+
+ query := database.DB.Model(&models.DistrictSite{}).Where("status = ?", "approved")
+ if district != "" {
+ query = query.Where("district = ?", district)
+ }
+ if search != "" {
+ query = query.Where("title ILIKE ? OR url ILIKE ?", "%"+search+"%", "%"+search+"%")
+ }
+
+ query.Count(&total)
+ pagination.Apply(query.Order("created_at desc")).Preload("Submitter").Preload("ReviewedBy").Preload("Tags").Find(&sites)
+
+ return sites, total
+}
+
+func CountPendingDistrictSites() int64 {
+ var count int64
+ database.DB.Model(&models.DistrictSite{}).Where("status IN ?", []string{"pending", "hold"}).Count(&count)
+ return count
+}
+
+func CountApprovedSitesByDistrict(slug string) int64 {
+ var count int64
+ database.DB.Model(&models.DistrictSite{}).Where("district = ? AND status = ?", slug, "approved").Count(&count)
+ return count
+}
+
+func FindOrCreateTag(name string) (*models.DistrictTag, error) {
+ var tag models.DistrictTag
+ normalized := strings.ToLower(strings.TrimSpace(name))
+ err := database.DB.Where("name = ?", normalized).FirstOrCreate(&tag, models.DistrictTag{Name: normalized}).Error
+ return &tag, err
+}
+
+func ReplaceDistrictSiteTags(site *models.DistrictSite, tags []models.DistrictTag) error {
+ return database.DB.Model(site).Association("Tags").Replace(tags)
+}
+
+func ListPopularTags(limit int) []models.DistrictTag {
+ var tags []models.DistrictTag
+ database.DB.Raw(`
+ SELECT dt.*, COUNT(dst.district_site_id) as count
+ FROM district_tags dt
+ INNER JOIN district_site_tags dst ON dst.district_tag_id = dt.id
+ INNER JOIN district_sites ds ON ds.id = dst.district_site_id AND ds.status = 'approved'
+ GROUP BY dt.id
+ ORDER BY count DESC
+ LIMIT ?
+ `, limit).Scan(&tags)
+ return tags
+}
+
+func ListApprovedSitesWithoutThumbnail() []models.DistrictSite {
+ var sites []models.DistrictSite
+ database.DB.Where("status = ? AND thumbnail_url = ?", "approved", "").Find(&sites)
+ return sites
+}
+
+func ListApprovedSitesForThumbnail() []models.DistrictSite {
+ var sites []models.DistrictSite
+ database.DB.Where("status = ?", "approved").Find(&sites)
+ return sites
+} \ No newline at end of file
diff --git a/shrine/router/council.go b/shrine/router/council.go
index af6815a..9603b53 100644
--- a/shrine/router/council.go
+++ b/shrine/router/council.go
@@ -39,4 +39,10 @@ func init() {
urls.Path(enums.DELETE, "/bannedips/:id", auth.RequireAdmin(controllers.DeleteIPBanController), "bannedipdelete")
urls.Path(enums.POST, "/upload", auth.RequireAdmin(controllers.UploadImageController), "upload")
+
+ urls.Path(enums.GET, "/districts/requests", auth.RequireStaff(controllers.ListSiteRequestsController), "districtreqs")
+ urls.Path(enums.POST, "/districts/sites/:ref/review", auth.RequireStaff(controllers.ReviewSiteController), "districtreview")
+ urls.Path(enums.GET, "/districts/sites", auth.RequireStaff(controllers.ListAdminSitesController), "districtsites")
+ urls.Path(enums.PATCH, "/districts/sites/:ref", auth.RequireStaff(controllers.EditSiteController), "districtedit")
+ urls.Path(enums.GET, "/districts/pending", auth.RequireStaff(controllers.CountPendingSitesController), "districtpending")
} \ No newline at end of file
diff --git a/shrine/router/district.go b/shrine/router/district.go
new file mode 100644
index 0000000..f30d6b7
--- /dev/null
+++ b/shrine/router/district.go
@@ -0,0 +1,16 @@
+package router
+
+import (
+ "shrine/controllers"
+ "shrine/enums"
+ "shrine/utils/auth"
+ "shrine/utils/urls"
+)
+
+func init() {
+ urls.SetNamespace("districts")
+
+ urls.Path(enums.GET, "", controllers.ListDistrictsController, "list")
+ urls.Path(enums.GET, "/sites", controllers.ListDistrictSitesController, "sites")
+ urls.Path(enums.POST, "/sites", auth.RequireAuthentication(controllers.SubmitSiteController), "submit")
+} \ No newline at end of file
diff --git a/shrine/services/district.go b/shrine/services/district.go
new file mode 100644
index 0000000..b1be105
--- /dev/null
+++ b/shrine/services/district.go
@@ -0,0 +1,249 @@
+package services
+
+import (
+ "fmt"
+ "net/url"
+ "shrine/enums"
+ "shrine/messages"
+ "shrine/models"
+ "shrine/repositories"
+ "shrine/types/district"
+ "shrine/types/hypertext"
+ "shrine/utils/districts"
+ "shrine/utils/meta"
+ "strings"
+ "time"
+)
+
+func ListDistricts() []district.DistrictResponse {
+ responses := make([]district.DistrictResponse, len(districts.All))
+ for index, entry := range districts.All {
+ responses[index] = district.DistrictResponse{
+ Slug: string(entry.Slug),
+ Name: entry.Name,
+ Description: entry.Description,
+ Background: entry.Background,
+ Foreground: entry.Foreground,
+ Detail: entry.Detail,
+ SiteCount: repositories.CountApprovedSitesByDistrict(string(entry.Slug)),
+ }
+ }
+ return responses
+}
+
+func ListDistrictSites(pagination meta.Pagination, slug string, tag string, search string) ([]district.SiteResponse, int64) {
+ sites, total := repositories.ListDistrictSites(pagination, slug, tag, search)
+ responses := make([]district.SiteResponse, len(sites))
+ for index, site := range sites {
+ responses[index] = site.ToResponse()
+ }
+ return responses, total
+}
+
+func SubmitSite(userID uint, request district.SubmitSiteRequest) (*district.SiteRequestResponse, *hypertext.ServiceError) {
+ if !districts.IsValid(request.District) {
+ return nil, fail(enums.BadRequest, messages.InvalidDistrict)
+ }
+
+ title := strings.TrimSpace(request.Title)
+ if title == "" || len(title) > 200 {
+ return nil, fail(enums.BadRequest, messages.SiteTitleRequired)
+ }
+
+ siteURL := strings.TrimSpace(request.URL)
+ if !isValidURL(siteURL) {
+ return nil, fail(enums.BadRequest, messages.SiteURLRequired)
+ }
+
+ description := strings.TrimSpace(request.Description)
+ if len(description) > 1000 {
+ return nil, fail(enums.BadRequest, messages.SiteDescriptionLong)
+ }
+
+ tags, serviceErr := validateTags(request.Tags)
+ if serviceErr != nil {
+ return nil, serviceErr
+ }
+
+ existing, _ := repositories.FindDistrictSiteByURL(siteURL)
+ if existing != nil {
+ return nil, fail(enums.Conflict, messages.SiteURLTaken)
+ }
+
+ site := models.DistrictSite{
+ District: request.District,
+ SubmitterID: userID,
+ Title: title,
+ URL: siteURL,
+ Description: description,
+ Status: string(enums.SitePending),
+ }
+
+ if err := repositories.CreateDistrictSite(&site); err != nil {
+ return nil, fail(enums.Internal, messages.FailedSubmitSite)
+ }
+
+ if len(tags) > 0 {
+ repositories.ReplaceDistrictSiteTags(&site, tags)
+ }
+
+ site.Submitter = models.User{}
+ record, _ := repositories.FindDistrictSiteByRef(site.Ref)
+ response := record.ToRequestResponse()
+ return &response, nil
+}
+
+func ReviewSite(adminID uint, ref string, request district.ReviewSiteRequest) (*district.SiteRequestResponse, *hypertext.ServiceError) {
+ site, serviceErr := resolveDistrictSite(ref)
+ if serviceErr != nil {
+ return nil, serviceErr
+ }
+
+ status := enums.SiteStatus(request.Status)
+ switch status {
+ case enums.SiteApproved, enums.SiteDenied, enums.SiteHold:
+ default:
+ return nil, fail(enums.BadRequest, messages.InvalidSiteStatus)
+ }
+
+ site.Status = request.Status
+ site.ReviewedByID = &adminID
+ now := time.Now()
+ site.ReviewedAt = &now
+
+ if err := repositories.UpdateDistrictSite(site); err != nil {
+ return nil, fail(enums.Internal, messages.FailedReviewSite)
+ }
+
+ var auditMessage string
+ switch status {
+ case enums.SiteApproved:
+ auditMessage = fmt.Sprintf(messages.AuditApprovedSite, site.Ref)
+ case enums.SiteDenied:
+ auditMessage = fmt.Sprintf(messages.AuditDeniedSite, site.Ref)
+ case enums.SiteHold:
+ auditMessage = fmt.Sprintf(messages.AuditHeldSite, site.Ref)
+ }
+
+ repositories.LogAction(adminID, "district.review", "site", site.Ref, auditMessage, request)
+
+ if status == enums.SiteApproved {
+ go GenerateSiteThumbnail(site.Ref, site.URL)
+ }
+
+ record, _ := repositories.FindDistrictSiteByRef(site.Ref)
+ response := record.ToRequestResponse()
+ return &response, nil
+}
+
+func EditSite(adminID uint, ref string, request district.EditSiteRequest) (*district.AdminSiteResponse, *hypertext.ServiceError) {
+ site, serviceErr := resolveDistrictSite(ref)
+ if serviceErr != nil {
+ return nil, serviceErr
+ }
+
+ if request.Title != nil {
+ title := strings.TrimSpace(*request.Title)
+ if title == "" || len(title) > 200 {
+ return nil, fail(enums.BadRequest, messages.SiteTitleRequired)
+ }
+ site.Title = title
+ }
+
+ if request.Description != nil {
+ description := strings.TrimSpace(*request.Description)
+ if len(description) > 1000 {
+ return nil, fail(enums.BadRequest, messages.SiteDescriptionLong)
+ }
+ site.Description = description
+ }
+
+ if request.District != nil {
+ if !districts.IsValid(*request.District) {
+ return nil, fail(enums.BadRequest, messages.InvalidDistrict)
+ }
+ site.District = *request.District
+ }
+
+ if request.Tags != nil {
+ tags, tagErr := validateTags(request.Tags)
+ if tagErr != nil {
+ return nil, tagErr
+ }
+ repositories.ReplaceDistrictSiteTags(site, tags)
+ }
+
+ if err := repositories.UpdateDistrictSite(site); err != nil {
+ return nil, fail(enums.Internal, messages.FailedEditSite)
+ }
+
+ repositories.LogAction(adminID, "district.edit", "site", site.Ref, fmt.Sprintf(messages.AuditEditedSite, site.Ref), request)
+
+ record, _ := repositories.FindDistrictSiteByRef(site.Ref)
+ response := record.ToAdminResponse()
+ return &response, nil
+}
+
+func ListSiteRequests(pagination meta.Pagination, status string) ([]district.SiteRequestResponse, int64) {
+ sites, total := repositories.ListDistrictSiteRequests(pagination, status)
+ responses := make([]district.SiteRequestResponse, len(sites))
+ for index, site := range sites {
+ responses[index] = site.ToRequestResponse()
+ }
+ return responses, total
+}
+
+func ListAdminSites(pagination meta.Pagination, slug string, search string) ([]district.AdminSiteResponse, int64) {
+ sites, total := repositories.ListAllDistrictSites(pagination, slug, search)
+ responses := make([]district.AdminSiteResponse, len(sites))
+ for index, site := range sites {
+ responses[index] = site.ToAdminResponse()
+ }
+ return responses, total
+}
+
+func CountPendingSites() int64 {
+ return repositories.CountPendingDistrictSites()
+}
+
+func resolveDistrictSite(ref string) (*models.DistrictSite, *hypertext.ServiceError) {
+ site, err := repositories.FindDistrictSiteByRef(ref)
+ if err != nil {
+ return nil, fail(enums.NotFound, messages.SiteNotFound)
+ }
+ return site, nil
+}
+
+func isValidURL(raw string) bool {
+ if raw == "" {
+ return false
+ }
+ parsed, err := url.ParseRequestURI(raw)
+ if err != nil {
+ return false
+ }
+ return parsed.Scheme == "http" || parsed.Scheme == "https"
+}
+
+func validateTags(raw []string) ([]models.DistrictTag, *hypertext.ServiceError) {
+ if len(raw) > 5 {
+ return nil, fail(enums.BadRequest, messages.TooManyTags)
+ }
+
+ var tags []models.DistrictTag
+ for _, name := range raw {
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ continue
+ }
+ if len(trimmed) > 50 {
+ return nil, fail(enums.BadRequest, messages.TagTooLong)
+ }
+ tag, err := repositories.FindOrCreateTag(trimmed)
+ if err != nil {
+ return nil, fail(enums.Internal, messages.FailedSubmitSite)
+ }
+ tags = append(tags, *tag)
+ }
+ return tags, nil
+} \ No newline at end of file
diff --git a/shrine/services/thumbnail.go b/shrine/services/thumbnail.go
new file mode 100644
index 0000000..6f4e8f2
--- /dev/null
+++ b/shrine/services/thumbnail.go
@@ -0,0 +1,151 @@
+package services
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/png"
+ "shrine/repositories"
+ "shrine/utils/logger"
+ "shrine/utils/storage"
+ "time"
+
+ "github.com/chromedp/chromedp"
+ "golang.org/x/image/draw"
+)
+
+const (
+ thumbnailWidth = 320
+ thumbnailHeight = 200
+ captureWidth = 1280
+ captureHeight = 800
+ captureTimeout = 30 * time.Second
+)
+
+func GenerateThumbnails() {
+ sites := repositories.ListApprovedSitesForThumbnail()
+ if len(sites) == 0 {
+ return
+ }
+
+ logger.Infof("Thumbnails", "Generating thumbnails for %d sites", len(sites))
+
+ opts := append(chromedp.DefaultExecAllocatorOptions[:],
+ chromedp.WindowSize(captureWidth, captureHeight),
+ chromedp.Flag("headless", true),
+ chromedp.Flag("disable-gpu", true),
+ chromedp.Flag("no-sandbox", true),
+ )
+
+ allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
+ defer allocCancel()
+
+ for _, site := range sites {
+ var screenshot []byte
+
+ ctx, cancel := chromedp.NewContext(allocCtx)
+ timeoutCtx, timeoutCancel := context.WithTimeout(ctx, captureTimeout)
+
+ err := chromedp.Run(timeoutCtx,
+ chromedp.Navigate(site.URL),
+ chromedp.Sleep(2*time.Second),
+ chromedp.FullScreenshot(&screenshot, 90),
+ )
+
+ timeoutCancel()
+ cancel()
+
+ if err != nil {
+ logger.Errorf("Thumbnails", "Failed to capture %s: %v", site.URL, err)
+ continue
+ }
+
+ resized, err := resizeScreenshot(screenshot)
+ if err != nil {
+ logger.Errorf("Thumbnails", "Failed to resize %s: %v", site.URL, err)
+ continue
+ }
+
+ path := fmt.Sprintf("districts/thumbnails/%s.png", site.Ref)
+ err = storage.Upload(path, bytes.NewReader(resized), int64(len(resized)), "image/png")
+ if err != nil {
+ logger.Errorf("Thumbnails", "Failed to upload thumbnail for %s: %v", site.URL, err)
+ continue
+ }
+
+ site.ThumbnailURL = path
+ repositories.UpdateDistrictSite(&site)
+
+ logger.Infof("Thumbnails", "Generated thumbnail for %s", site.URL)
+ }
+
+ logger.Successf("Thumbnails", "Thumbnail generation complete")
+}
+
+func GenerateSiteThumbnail(ref string, siteURL string) {
+ opts := append(chromedp.DefaultExecAllocatorOptions[:],
+ chromedp.WindowSize(captureWidth, captureHeight),
+ chromedp.Flag("headless", true),
+ chromedp.Flag("disable-gpu", true),
+ chromedp.Flag("no-sandbox", true),
+ )
+
+ allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
+ defer allocCancel()
+
+ ctx, cancel := chromedp.NewContext(allocCtx)
+ defer cancel()
+ timeoutCtx, timeoutCancel := context.WithTimeout(ctx, captureTimeout)
+ defer timeoutCancel()
+
+ var screenshot []byte
+ err := chromedp.Run(timeoutCtx,
+ chromedp.Navigate(siteURL),
+ chromedp.Sleep(2*time.Second),
+ chromedp.FullScreenshot(&screenshot, 90),
+ )
+ if err != nil {
+ logger.Errorf("Thumbnails", "Failed to capture %s: %v", siteURL, err)
+ return
+ }
+
+ resized, err := resizeScreenshot(screenshot)
+ if err != nil {
+ logger.Errorf("Thumbnails", "Failed to resize %s: %v", siteURL, err)
+ return
+ }
+
+ path := fmt.Sprintf("districts/thumbnails/%s.png", ref)
+ err = storage.Upload(path, bytes.NewReader(resized), int64(len(resized)), "image/png")
+ if err != nil {
+ logger.Errorf("Thumbnails", "Failed to upload thumbnail for %s: %v", siteURL, err)
+ return
+ }
+
+ site, findErr := repositories.FindDistrictSiteByRef(ref)
+ if findErr != nil {
+ return
+ }
+ site.ThumbnailURL = path
+ repositories.UpdateDistrictSite(site)
+
+ logger.Infof("Thumbnails", "Generated thumbnail for %s", siteURL)
+}
+
+func resizeScreenshot(data []byte) ([]byte, error) {
+ src, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+
+ dst := image.NewRGBA(image.Rect(0, 0, thumbnailWidth, thumbnailHeight))
+ draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, dst); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+} \ No newline at end of file
diff --git a/shrine/shrine/main.go b/shrine/shrine/main.go
index 420d2eb..135cf05 100644
--- a/shrine/shrine/main.go
+++ b/shrine/shrine/main.go
@@ -6,6 +6,7 @@ import (
"os/signal"
"shrine/config"
"shrine/database"
+ "shrine/jobs"
"shrine/middleware"
"shrine/router"
"shrine/utils/logger"
@@ -43,10 +44,12 @@ func main() {
}
}()
+ jobs.Start()
logger.Successf("Main", "Server started on %s:%d", config.Server.Host, config.Server.Port)
<-quit
logger.Infof("Main", "Shutting down gracefully...")
+ jobs.Stop()
if err := app.Shutdown(); err != nil {
logger.Errorf("Main", "Error during server shutdown: %v", err)
diff --git a/shrine/types/district/request.go b/shrine/types/district/request.go
new file mode 100644
index 0000000..b5743ad
--- /dev/null
+++ b/shrine/types/district/request.go
@@ -0,0 +1,20 @@
+package district
+
+type SubmitSiteRequest struct {
+ District string `json:"district"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Description string `json:"description"`
+ Tags []string `json:"tags"`
+}
+
+type ReviewSiteRequest struct {
+ Status string `json:"status"`
+}
+
+type EditSiteRequest struct {
+ Title *string `json:"title"`
+ Description *string `json:"description"`
+ District *string `json:"district"`
+ Tags []string `json:"tags"`
+} \ No newline at end of file
diff --git a/shrine/types/district/response.go b/shrine/types/district/response.go
new file mode 100644
index 0000000..359a3ef
--- /dev/null
+++ b/shrine/types/district/response.go
@@ -0,0 +1,48 @@
+package district
+
+import (
+ "shrine/types/user"
+ "time"
+)
+
+type DistrictResponse struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Background string `json:"background"`
+ Foreground string `json:"foreground"`
+ Detail string `json:"detail"`
+ SiteCount int64 `json:"site_count"`
+}
+
+type TagResponse struct {
+ Name string `json:"name"`
+ Count int64 `json:"count"`
+}
+
+type SiteResponse struct {
+ Ref string `json:"ref"`
+ District string `json:"district"`
+ DistrictSlug string `json:"district_slug"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Description string `json:"description"`
+ ThumbnailURL string `json:"thumbnail_url"`
+ Tags []string `json:"tags"`
+ Submitter user.CitizenSummaryResponse `json:"submitter"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type SiteRequestResponse struct {
+ SiteResponse
+ Status string `json:"status"`
+ ReviewedBy *user.CitizenSummaryResponse `json:"reviewed_by"`
+ ReviewedAt *time.Time `json:"reviewed_at"`
+}
+
+type AdminSiteResponse struct {
+ SiteResponse
+ Status string `json:"status"`
+ ReviewedBy *user.CitizenSummaryResponse `json:"reviewed_by"`
+ ReviewedAt *time.Time `json:"reviewed_at"`
+} \ No newline at end of file
diff --git a/shrine/utils/districts/registry.go b/shrine/utils/districts/registry.go
new file mode 100644
index 0000000..dca4e22
--- /dev/null
+++ b/shrine/utils/districts/registry.go
@@ -0,0 +1,44 @@
+package districts
+
+import "shrine/enums"
+
+type District struct {
+ Slug enums.DistrictSlug
+ Name string
+ Description string
+ Background string
+ Foreground string
+ Detail string
+}
+
+var All = []District{
+ {enums.Arcadia, "Arcadia", "Video games, puzzles, toys", "#141c38", "#90c8ff", "#6898d0"},
+ {enums.Arles, "Arles", "Drawings, photos, visual art", "#281420", "#ff98b0", "#d07088"},
+ {enums.Hollywood, "Hollywood", "Cartoons, movies, western media", "#281808", "#ffb050", "#d08828"},
+ {enums.Oxford, "Oxford", "Books, literature, poetry", "#1c1028", "#c898f0", "#9870c0"},
+ {enums.Petsburg, "Petsburg", "Animals and people who love them", "#102018", "#60e888", "#40b868"},
+ {enums.Purgatory, "Purgatory", "Horror, dark, gothic", "#180c30", "#b080ff", "#8858d0"},
+ {enums.SiliconValley, "Silicon Valley", "Tech, programming, computers", "#102028", "#50d8c8", "#38a898"},
+ {enums.SilverLake, "Silver Lake", "Music, bands, concerts", "#141c30", "#80b8f0", "#5888c0"},
+ {enums.StratfordUponAvon, "Stratford-upon-Avon", "Writers and their writing", "#281010", "#ff8870", "#d06048"},
+ {enums.Tokyo, "Tokyo", "Anime, manga, and the far east", "#280818", "#ff70a8", "#d04880"},
+}
+
+var lookup map[enums.DistrictSlug]District
+
+func init() {
+ lookup = make(map[enums.DistrictSlug]District, len(All))
+ for _, entry := range All {
+ lookup[entry.Slug] = entry
+ }
+}
+
+func Find(slug enums.DistrictSlug) (District, bool) {
+ entry, ok := lookup[slug]
+ return entry, ok
+}
+
+func IsValid(slug string) bool {
+ _, ok := lookup[enums.DistrictSlug(slug)]
+ return ok
+} \ No newline at end of file