Skip to content

Commit a74b033

Browse files
committed
feat(newsroom): add CMS-backed newsroom
1 parent 05cafc6 commit a74b033

45 files changed

Lines changed: 5212 additions & 10 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"skycrypt-frontend": minor
3+
---
4+
5+
Add the CMS-backed newsroom with post listing, article pages, previews, and sitemap entries.

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,10 @@ MC_ID_CLIENT_SECRET=""
3838
# Patreon API
3939
# https://www.patreon.com/portal/registration/register-clients
4040
PATREON_CLIENT_ID=""
41-
PATREON_CLIENT_SECRET=""
41+
PATREON_CLIENT_SECRET=""
42+
43+
# Payload CMS
44+
PUBLIC_CMS_URL="https://cms.shiiyu.moe"
45+
SERVER_CMS_API_URL="https://cms.shiiyu.moe" # If you're using docker, set this to the name of the CMS container
46+
CMS_API_TOKEN="" # Users API Key generated in CMS admin (Users → API Key tab)
47+
CMS_PREVIEW_TOKEN="" # Must match CMS_PREVIEW_TOKEN in SkyCrypt-CMS .env

eslint.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,5 @@ export default defineConfig(
5858
"svelte/no-useless-mustaches": "off"
5959
}
6060
},
61-
{ ignores: ["**/.DS_Store", "**/node_modules/", "**/build/", "**/.svelte-kit/", "**/package/", "**/.env", "**/.env.*", "**/pnpm-lock.yaml", "**/package-lock.json", "**/yarn.lock", "**/static/", "**/cache/", "**/api/*-generated-zod.ts"] }
61+
{ ignores: ["**/.DS_Store", "**/node_modules/", "**/build/", "**/.svelte-kit/", "**/package/", "**/.env", "**/.env.*", "**/pnpm-lock.yaml", "**/package-lock.json", "**/yarn.lock", "**/static/", "**/cache/", "**/api/*-generated-zod.ts", "**/api/cms-generated.ts"] }
6262
);

orval.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,22 @@ export default defineConfig({
2929
hooks: {
3030
afterAllFilesWrite: "prettier --write ./src/lib/shared/api/orval-generated-zod.ts"
3131
}
32+
},
33+
cms: {
34+
input: "http://localhost:3000/api/openapi.json",
35+
output: {
36+
target: "./src/lib/shared/api/cms-generated.ts",
37+
client: "fetch",
38+
tsconfig: "./tsconfig.json",
39+
override: {
40+
mutator: {
41+
path: "./src/lib/shared/api/mutator/cms-instance.ts",
42+
name: "cmsFetch"
43+
}
44+
}
45+
},
46+
hooks: {
47+
afterAllFilesWrite: "prettier --write ./src/lib/shared/api/cms-generated.ts"
48+
}
3249
}
3350
});

pnpm-lock.yaml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/hooks.server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { building } from "$app/environment";
2+
import { env as envPublic } from "$env/dynamic/public";
23
import { auth } from "$lib/server/auth";
34
import { UserRole } from "$lib/shared/roles";
45
import { runMigrations } from "$src/lib/server/db/migrate";
@@ -28,7 +29,18 @@ const headersHandler = (async ({ event, resolve }) => {
2829
response.headers.set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), encrypted-media=(), fullscreen=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), geolocation=()");
2930
response.headers.set("X-Content-Type-Options", "nosniff");
3031
response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
31-
response.headers.set("X-Frame-Options", "DENY");
32+
33+
// Clickjacking protection. The newsroom draft preview must be embeddable in the Payload
34+
// CMS admin's live-preview iframe, so allow only the CMS origin to frame that one route;
35+
// everything else stays frame-locked.
36+
const isNewsroomPreview = url.pathname.startsWith("/newsroom/") && url.searchParams.get("preview") === "1";
37+
if (isNewsroomPreview) {
38+
const cms = envPublic.PUBLIC_CMS_URL?.trim();
39+
response.headers.append("Content-Security-Policy", `frame-ancestors 'self'${cms ? ` ${cms}` : ""}`);
40+
} else {
41+
response.headers.set("X-Frame-Options", "DENY");
42+
response.headers.append("Content-Security-Policy", "frame-ancestors 'none'");
43+
}
3244

3345
// Cross-Origin policies
3446
// COEP intentionally unsafe-none: tightening would require all cross-origin
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script lang="ts">
2+
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
3+
import ChevronRight from "@lucide/svelte/icons/chevron-right";
4+
import ChevronsLeft from "@lucide/svelte/icons/chevrons-left";
5+
import ChevronsRight from "@lucide/svelte/icons/chevrons-right";
6+
import { Button } from "bits-ui";
7+
8+
interface Props {
9+
page: number;
10+
totalPages: number;
11+
baseHref: string;
12+
}
13+
14+
let { page, totalPages, baseHref }: Props = $props();
15+
16+
const hrefFor = (n: number) => {
17+
if (n <= 1) return baseHref;
18+
const sep = baseHref.includes("?") ? "&" : "?";
19+
return `${baseHref}${sep}page=${n}`;
20+
};
21+
22+
const atFirst = $derived(page <= 1);
23+
const atLast = $derived(page >= totalPages);
24+
25+
const btnClass = "flex size-9 items-center justify-center rounded-lg bg-background-grey text-text transition-all duration-150 ease-out hover:scale-105 hover:bg-background-lore aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40 aria-disabled:hover:scale-100 aria-disabled:hover:bg-background-grey";
26+
</script>
27+
28+
{#if totalPages > 1}
29+
<nav aria-label="Pagination" class="flex items-center justify-end gap-1">
30+
<span class="px-3 text-sm font-semibold text-text/80 tabular-nums">
31+
Page <span class="font-bold text-text">{page}</span> of <span class="font-bold text-text">{totalPages}</span>
32+
</span>
33+
<Button.Root href={atFirst ? undefined : hrefFor(1)} aria-disabled={atFirst} aria-label="First page" data-sveltekit-preload-data="hover" class={btnClass}>
34+
<ChevronsLeft class="size-4" />
35+
</Button.Root>
36+
<Button.Root href={atFirst ? undefined : hrefFor(page - 1)} aria-disabled={atFirst} aria-label="Previous page" data-sveltekit-preload-data="hover" class={btnClass}>
37+
<ChevronLeft class="size-4" />
38+
</Button.Root>
39+
<Button.Root href={atLast ? undefined : hrefFor(page + 1)} aria-disabled={atLast} aria-label="Next page" data-sveltekit-preload-data="hover" class={btnClass}>
40+
<ChevronRight class="size-4" />
41+
</Button.Root>
42+
<Button.Root href={atLast ? undefined : hrefFor(totalPages)} aria-disabled={atLast} aria-label="Last page" data-sveltekit-preload-data="hover" class={btnClass}>
43+
<ChevronsRight class="size-4" />
44+
</Button.Root>
45+
</nav>
46+
{/if}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<script lang="ts">
2+
import { getPreferences } from "$ctx";
3+
import TypeBadge from "$lib/components/newsroom/TypeBadge.svelte";
4+
import { clientLocale } from "$lib/hooks/client-locale.svelte";
5+
import { cn } from "$lib/shared/utils";
6+
import type { Author, AuthorView, Post } from "$types";
7+
import ImageIcon from "@lucide/svelte/icons/image";
8+
import Star from "@lucide/svelte/icons/star";
9+
import { Avatar, Button } from "bits-ui";
10+
11+
/** `as` sets the title's heading level so the card fits its surrounding document outline. */
12+
const { post, as = "h3" }: { post: Post; as?: "h2" | "h3" } = $props();
13+
14+
const preferences = getPreferences();
15+
16+
const dateFormatter = $derived(new Intl.DateTimeFormat(clientLocale.current, { year: "numeric", month: "long", day: "numeric" }));
17+
const formatDate = (iso: string | null | undefined): string => {
18+
if (!iso) return "";
19+
try {
20+
return dateFormatter.format(new Date(iso));
21+
} catch {
22+
return iso;
23+
}
24+
};
25+
26+
const authorOf = (author: Author | string): AuthorView => (typeof author === "string" ? { id: author, name: "Unknown" } : author);
27+
28+
const author = $derived(authorOf(post.author));
29+
const displayName = $derived(author.displayName?.trim() || author.name);
30+
const initials = $derived(displayName.slice(0, 2).toUpperCase());
31+
const thumb = $derived(post.heroImage?.sizes?.card ?? post.heroImage?.sizes?.thumbnail ?? (post.heroImage ? { url: post.heroImage.url, width: post.heroImage.width, height: post.heroImage.height } : null));
32+
const visibleTags = $derived(post.tags?.slice(0, 3) ?? []);
33+
const overflowTags = $derived((post.tags?.length ?? 0) - visibleTags.length);
34+
</script>
35+
36+
<Button.Root href="/newsroom/{post.slug}" data-sveltekit-preload-data="hover" class={cn("group relative flex h-full w-full min-w-0 flex-col overflow-hidden rounded-lg text-left", preferences.performanceMode ? "bg-background-grey" : "backdrop-blur-lg backdrop-brightness-150 backdrop-contrast-60 dark:backdrop-brightness-50 dark:backdrop-contrast-100")}>
37+
<div class="relative aspect-video w-full overflow-hidden bg-background-lore">
38+
{#if thumb && post.heroImage}
39+
<Avatar.Root class="size-full transition-transform duration-300 ease-out group-hover:scale-105">
40+
<Avatar.Image src={thumb.url} alt={post.heroImage.alt ?? ""} width={thumb.width} height={thumb.height} loading="lazy" class="size-full object-cover" />
41+
<Avatar.Fallback class="flex size-full items-center justify-center bg-text/10">
42+
<ImageIcon class="size-6" aria-label="Image failed to load" />
43+
</Avatar.Fallback>
44+
</Avatar.Root>
45+
{/if}
46+
<div class="pointer-events-none absolute inset-0 bg-linear-to-t from-background/70 via-background/10 to-transparent group-hover:opacity-0 transition-opacity duration-300 ease-out"></div>
47+
{#if post.featured}
48+
<Star class="size-6 absolute top-2 left-2 shrink-0 fill-gold text-gold" aria-label="Featured" />
49+
{/if}
50+
</div>
51+
<div class="flex flex-1 flex-col gap-2.5 p-4">
52+
<div class="flex items-center justify-between gap-4 text-sm">
53+
<div class="flex min-w-0 items-center justify-center gap-2">
54+
{#if author.mcUuid}
55+
<Avatar.Root class="size-4 shrink-0">
56+
<Avatar.Image loading="lazy" src="https://nmsr.nickac.dev/face/{author.mcUuid}" alt={displayName} class="size-full [image-rendering:pixelated]" />
57+
<Avatar.Fallback class="flex size-full items-center justify-center bg-text/10 text-[0.5rem] font-semibold text-text/60 uppercase">{initials}</Avatar.Fallback>
58+
</Avatar.Root>
59+
{/if}
60+
<span class="truncate text-text/60">{displayName}</span>
61+
</div>
62+
<time datetime={post.publishedAt} class="shrink-0 text-text/60">{formatDate(post.publishedAt)}</time>
63+
</div>
64+
65+
<svelte:element this={as} class="text-xl leading-tight font-bold text-text transition-colors group-hover:text-hover">{post.title}</svelte:element>
66+
67+
{#if post.excerpt}
68+
<p class="line-clamp-3 text-sm leading-relaxed text-text/80">{post.excerpt}</p>
69+
{/if}
70+
71+
<div class="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
72+
<TypeBadge type={post.type} />
73+
{#each visibleTags as tag (tag)}
74+
<span class="rounded-full bg-text/10 px-2 py-0.5 text-[10px] font-medium text-text/70">#{tag}</span>
75+
{/each}
76+
{#if overflowTags > 0}
77+
<span class="rounded-full bg-text/10 px-2 py-0.5 text-[10px] font-medium text-text/50">+{overflowTags}</span>
78+
{/if}
79+
</div>
80+
</div>
81+
</Button.Root>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script lang="ts">
2+
import type { Block } from "$types";
3+
import Image from "./lexical/elements/Image.svelte";
4+
import RichText from "./lexical/elements/RichText.svelte";
5+
6+
const { body }: { body: Block[] } = $props();
7+
</script>
8+
9+
{#each body as block, i (block.id ?? i)}
10+
{#if block.blockType === "image"}
11+
<Image {block} />
12+
{:else if block.blockType === "richText"}
13+
<RichText {block} />
14+
{/if}
15+
{/each}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script lang="ts" module>
2+
import type { PostType } from "$types";
3+
4+
const TYPE_STYLES: Record<PostType, string> = {
5+
announcement: "bg-gold/15 text-gold",
6+
news: "bg-link/15 text-link",
7+
update: "bg-minecraft-a/15 text-minecraft-a",
8+
changelog: "bg-minecraft-d/15 text-minecraft-d",
9+
guide: "bg-minecraft-b/15 text-minecraft-b",
10+
event: "bg-minecraft-c/15 text-minecraft-c"
11+
};
12+
13+
export const POST_TYPE_LABELS: Record<PostType, string> = {
14+
announcement: "Announcement",
15+
news: "News",
16+
update: "Update",
17+
changelog: "Changelog",
18+
guide: "Guide",
19+
event: "Event"
20+
};
21+
</script>
22+
23+
<script lang="ts">
24+
import { cn } from "$lib/shared/utils";
25+
26+
const { type, class: className }: { type: PostType; class?: string } = $props();
27+
</script>
28+
29+
<span class={cn("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold tracking-wide uppercase backdrop-blur-sm", TYPE_STYLES[type], className)}>
30+
{POST_TYPE_LABELS[type]}
31+
</span>

0 commit comments

Comments
 (0)