diff options
39 files changed, 2191 insertions, 5 deletions
diff --git a/garden/public/images/background.webp b/garden/public/images/backgrounds/background.webp Binary files differindex 9e37b7f..9e37b7f 100644 --- a/garden/public/images/background.webp +++ b/garden/public/images/backgrounds/background.webp diff --git a/garden/public/images/districts/arcadia.png b/garden/public/images/districts/arcadia.png Binary files differnew file mode 100644 index 0000000..821955e --- /dev/null +++ b/garden/public/images/districts/arcadia.png diff --git a/garden/public/images/districts/arles.png b/garden/public/images/districts/arles.png Binary files differnew file mode 100644 index 0000000..43322e2 --- /dev/null +++ b/garden/public/images/districts/arles.png diff --git a/garden/public/images/districts/hollywood.png b/garden/public/images/districts/hollywood.png Binary files differnew file mode 100644 index 0000000..56e52a2 --- /dev/null +++ b/garden/public/images/districts/hollywood.png diff --git a/garden/public/images/districts/oxford.png b/garden/public/images/districts/oxford.png Binary files differnew file mode 100644 index 0000000..d1016d2 --- /dev/null +++ b/garden/public/images/districts/oxford.png diff --git a/garden/public/images/districts/petsburg.png b/garden/public/images/districts/petsburg.png Binary files differnew file mode 100644 index 0000000..a8bb491 --- /dev/null +++ b/garden/public/images/districts/petsburg.png diff --git a/garden/public/images/districts/purgatory.png b/garden/public/images/districts/purgatory.png Binary files differnew file mode 100644 index 0000000..85aac4f --- /dev/null +++ b/garden/public/images/districts/purgatory.png diff --git a/garden/public/images/districts/silicon.png b/garden/public/images/districts/silicon.png Binary files differnew file mode 100644 index 0000000..bf630f9 --- /dev/null +++ b/garden/public/images/districts/silicon.png diff --git a/garden/public/images/districts/silver.png b/garden/public/images/districts/silver.png Binary files differnew file mode 100644 index 0000000..250613a --- /dev/null +++ b/garden/public/images/districts/silver.png diff --git a/garden/public/images/districts/stratford.png b/garden/public/images/districts/stratford.png Binary files differnew file mode 100644 index 0000000..ba18bc5 --- /dev/null +++ b/garden/public/images/districts/stratford.png diff --git a/garden/public/images/districts/tokyo.png b/garden/public/images/districts/tokyo.png Binary files differnew file mode 100644 index 0000000..56d1843 --- /dev/null +++ b/garden/public/images/districts/tokyo.png 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} × + </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 |
