Skip to content

Commit 88743ca

Browse files
RedStar071claudecodesmith-botkodiakhq[bot]
authored
feat(ui): add changelog page and dedicated OG images (#301)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent 8718987 commit 88743ca

22 files changed

Lines changed: 1340 additions & 15 deletions

.env.example

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@ NUXT_BETTER_AUTH_SECRET=
2525
# Session cookie name — MUST differ per deployment to prevent cookie bleed across subdomains
2626
# Production (wolfstar.rocks): leave blank (defaults to wolfstar-session)
2727
# Beta/staging (beta.wolfstar.rocks): wolfstar-session-beta
28-
NUXT_SESSION_COOKIE_NAME=
29-
30-
# Secret for generating OG images (generate a secure random string)
28+
NUXT_SESSION_COOKIE_NAME=
29+
30+
# Nuxt Studio GitHub OAuth credentials (local development only)
31+
# Nuxt Studio is registered only when running `nuxt dev`, so it is never exposed
32+
# in production. Configure the callback URL as http://localhost:3000/__nuxt_studio/auth/github
33+
NUXT_STUDIO_AUTH_GITHUB_CLIENT_ID=
34+
NUXT_STUDIO_AUTH_GITHUB_CLIENT_SECRET=
35+
36+
# Secret for generating OG images (generate a secure random string)
3137
# You can use: openssl rand -base64 32
3238
NUXT_IMAGE_PROXY_SECRET=
3339
# ===================================
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
<template>
2+
<OgLayout
3+
:class="[colorMode === 'light' ? ['bg-white', 'text-gray-900'] : ['text-white']]"
4+
:style="{
5+
backgroundColor: colorMode === 'dark' ? '#0a0a0a' : '#ffffff',
6+
backgroundImage:
7+
colorMode === 'dark'
8+
? `radial-gradient(circle at 50% 10%, rgba(${themeRgb}, 0.35) 0%, #0a0a0a 60%)`
9+
: `radial-gradient(circle at 50% 10%, rgba(${themeRgb}, 0.4) 0%, #ffffff 65%)`,
10+
}"
11+
>
12+
<div class="flex h-full w-full flex-col items-start justify-between">
13+
<!-- Brand -->
14+
<div class="flex flex-row items-center">
15+
<IconsWolfstar class="h-14 w-14" aria-hidden="true" />
16+
<p style="font-size: 32px" class="ml-3 font-bold">
17+
{{ siteName }}
18+
</p>
19+
</div>
20+
21+
<!-- Date + Title + Description -->
22+
<div class="flex w-full flex-col items-start">
23+
<span
24+
v-if="formattedDate"
25+
class="mb-4 text-[30px] font-semibold tracking-wide"
26+
:class="[colorMode === 'light' ? ['text-gray-600'] : ['text-white/75']]"
27+
>
28+
{{ formattedDate }}
29+
</span>
30+
<h1
31+
class="m-0 font-mono text-[70px] leading-none font-extrabold tracking-tight"
32+
style="display: block; text-overflow: ellipsis"
33+
:style="{ lineClamp: 2 }"
34+
>
35+
{{ title }}
36+
</h1>
37+
<p
38+
v-if="description"
39+
class="mt-6 text-[34px] leading-11 font-semibold tracking-wide"
40+
:class="[colorMode === 'light' ? ['text-gray-600'] : ['text-white/75']]"
41+
style="display: block; text-overflow: ellipsis"
42+
:style="{ lineClamp: 2 }"
43+
>
44+
{{ description }}
45+
</p>
46+
</div>
47+
48+
<!-- Authors -->
49+
<div v-if="authors.length" class="flex flex-row items-center">
50+
<div class="flex flex-row items-center">
51+
<span
52+
v-for="(author, index) in visibleAuthors"
53+
:key="author.name"
54+
class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full"
55+
:class="[colorMode === 'light' ? ['bg-gray-200'] : ['bg-white/15']]"
56+
:style="{
57+
marginLeft: index > 0 ? '-20px' : '0',
58+
border:
59+
colorMode === 'dark' ? '3px solid #0a0a0a' : '3px solid #ffffff',
60+
}"
61+
>
62+
<img
63+
v-if="author.avatar"
64+
:src="author.avatar"
65+
:alt="author.name"
66+
width="64"
67+
height="64"
68+
style="width: 100%; height: 100%; object-fit: cover"
69+
/>
70+
<span v-else class="text-[24px] font-semibold">
71+
{{ getInitials(author.name) }}
72+
</span>
73+
</span>
74+
<span
75+
v-if="extraCount > 0"
76+
class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full text-[24px] font-semibold"
77+
:class="[colorMode === 'light' ? ['bg-gray-200'] : ['bg-white/15']]"
78+
:style="{
79+
marginLeft: '-20px',
80+
border:
81+
colorMode === 'dark' ? '3px solid #0a0a0a' : '3px solid #ffffff',
82+
}"
83+
>
84+
+{{ extraCount }}
85+
</span>
86+
</div>
87+
<span
88+
class="ml-5 text-[30px] font-semibold"
89+
:class="[colorMode === 'light' ? ['text-gray-700'] : ['text-white/80']]"
90+
>
91+
{{ formattedAuthorNames }}
92+
</span>
93+
</div>
94+
</div>
95+
</OgLayout>
96+
</template>
97+
98+
<script setup lang="ts">
99+
import type { ResolvableValue } from "@unhead/vue";
100+
import { useOgImageRuntimeConfig } from "#og-image/app/utils";
101+
import { useSiteConfig } from "#site-config/app/composables";
102+
import { computed } from "vue";
103+
104+
export interface OgAuthor {
105+
name: string;
106+
avatar?: string;
107+
}
108+
109+
export interface BlogPostOGImageProps {
110+
colorMode?: "dark" | "light";
111+
title?: ResolvableValue<string>;
112+
description?: ResolvableValue<string>;
113+
date?: string;
114+
authors?: OgAuthor[];
115+
theme?: string;
116+
}
117+
118+
const {
119+
theme = BrandingColors.Secondary,
120+
title = "title",
121+
colorMode: propsColorMode,
122+
description,
123+
date = "",
124+
authors = [],
125+
} = defineProps<BlogPostOGImageProps>();
126+
127+
const HexRegex = /^#(?:[0-9a-f]{3}){1,2}$/i;
128+
129+
const runtimeConfig = useOgImageRuntimeConfig();
130+
131+
const colorMode = computed(() => propsColorMode || runtimeConfig.colorPreference || "light");
132+
133+
const themeHex = computed(() => {
134+
if (HexRegex.test(theme)) {
135+
return theme;
136+
}
137+
138+
if (HexRegex.test(`#${theme}`)) {
139+
return `#${theme}`;
140+
}
141+
142+
if (theme.startsWith("rgb")) {
143+
const rgb = theme
144+
.replace("rgb(", "")
145+
.replace("rgba(", "")
146+
.replace(")", "")
147+
.split(",")
148+
.map((v) => Number.parseInt(v.trim(), 10));
149+
const hex = rgb
150+
.map((v) => {
151+
const hex = v.toString(16);
152+
return hex.length === 1 ? `0${hex}` : hex;
153+
})
154+
.join("");
155+
return `#${hex}`;
156+
}
157+
return "#FFFFFF";
158+
});
159+
160+
const themeRgb = computed(() =>
161+
themeHex.value
162+
.replace("#", "")
163+
.match(/.{1,2}/g)
164+
?.map((v) => Number.parseInt(v, 16))
165+
.join(", "),
166+
);
167+
168+
const siteConfig = useSiteConfig();
169+
const siteName = computed(() => siteConfig.name);
170+
171+
const formattedDate = computed(() => {
172+
if (!date) return "";
173+
const parsed = new Date(date);
174+
if (Number.isNaN(parsed.getTime())) return date;
175+
return parsed.toLocaleDateString("en-US", {
176+
year: "numeric",
177+
month: "short",
178+
day: "numeric",
179+
timeZone: "UTC",
180+
});
181+
});
182+
183+
const MAX_VISIBLE_AUTHORS = 2;
184+
185+
const getInitials = (name: string) =>
186+
name
187+
.trim()
188+
.split(/\s+/)
189+
.map((part) => part[0] ?? "")
190+
.join("")
191+
.toUpperCase()
192+
.slice(0, 2);
193+
194+
const visibleAuthors = computed(() =>
195+
authors.length <= 3 ? authors : authors.slice(0, MAX_VISIBLE_AUTHORS),
196+
);
197+
198+
const extraCount = computed(() => (authors.length <= 3 ? 0 : authors.length - MAX_VISIBLE_AUTHORS));
199+
200+
const formattedAuthorNames = computed(() => {
201+
const names = authors.map((author) => author.name);
202+
if (names.length === 0) return "";
203+
if (names.length === 1) return names[0];
204+
if (names.length === 2) return `${names[0]} and ${names[1]}`;
205+
if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]}`;
206+
const shown = names.slice(0, MAX_VISIBLE_AUTHORS);
207+
const remaining = names.length - MAX_VISIBLE_AUTHORS;
208+
return `${shown.join(", ")} and ${remaining} others`;
209+
});
210+
</script>
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<template>
2+
<OgLayout
3+
:class="[colorMode === 'light' ? ['bg-white', 'text-gray-900'] : ['text-white']]"
4+
:style="{
5+
backgroundColor: colorMode === 'dark' ? '#0a0a0a' : '#ffffff',
6+
backgroundImage:
7+
colorMode === 'dark'
8+
? `radial-gradient(circle at 50% 10%, rgba(${themeRgb}, 0.35) 0%, #0a0a0a 60%)`
9+
: `radial-gradient(circle at 50% 10%, rgba(${themeRgb}, 0.4) 0%, #ffffff 65%)`,
10+
}"
11+
>
12+
<div class="flex h-full w-full flex-col items-start justify-between">
13+
<!-- Brand -->
14+
<div class="flex flex-row items-center">
15+
<IconsWolfstar class="h-14 w-14" aria-hidden="true" />
16+
<p style="font-size: 32px" class="ml-3 font-bold">
17+
{{ siteName }}
18+
</p>
19+
</div>
20+
21+
<!-- Title + Description -->
22+
<div class="flex w-full flex-col items-start">
23+
<h1
24+
class="m-0 font-mono text-[80px] leading-none font-extrabold tracking-tight"
25+
style="display: block; text-overflow: ellipsis"
26+
:style="{ lineClamp: description ? 2 : 3 }"
27+
>
28+
{{ title }}
29+
</h1>
30+
<p
31+
v-if="description"
32+
class="mt-6 text-[36px] leading-12 font-semibold tracking-wide"
33+
:class="[colorMode === 'light' ? ['text-gray-600'] : ['text-white/75']]"
34+
style="display: block; text-overflow: ellipsis"
35+
:style="{ lineClamp: 2 }"
36+
>
37+
{{ description }}
38+
</p>
39+
</div>
40+
41+
<!-- Spacer to balance the vertical layout -->
42+
<div />
43+
</div>
44+
</OgLayout>
45+
</template>
46+
47+
<script setup lang="ts">
48+
import type { ResolvableValue } from "@unhead/vue";
49+
import { useOgImageRuntimeConfig } from "#og-image/app/utils";
50+
import { useSiteConfig } from "#site-config/app/composables";
51+
import { computed } from "vue";
52+
53+
export interface ChangelogOGImageProps {
54+
colorMode?: "dark" | "light";
55+
title?: ResolvableValue<string>;
56+
description?: ResolvableValue<string>;
57+
theme?: string;
58+
}
59+
60+
const {
61+
theme = BrandingColors.Secondary,
62+
title = "Changelog",
63+
colorMode: propsColorMode,
64+
description,
65+
} = defineProps<ChangelogOGImageProps>();
66+
67+
const HexRegex = /^#(?:[0-9a-f]{3}){1,2}$/i;
68+
69+
const runtimeConfig = useOgImageRuntimeConfig();
70+
71+
const colorMode = computed(() => propsColorMode || runtimeConfig.colorPreference || "light");
72+
73+
const themeHex = computed(() => {
74+
if (HexRegex.test(theme)) {
75+
return theme;
76+
}
77+
78+
if (HexRegex.test(`#${theme}`)) {
79+
return `#${theme}`;
80+
}
81+
82+
if (theme.startsWith("rgb")) {
83+
const rgb = theme
84+
.replace("rgb(", "")
85+
.replace("rgba(", "")
86+
.replace(")", "")
87+
.split(",")
88+
.map((v) => Number.parseInt(v.trim(), 10));
89+
const hex = rgb
90+
.map((v) => {
91+
const hex = v.toString(16);
92+
return hex.length === 1 ? `0${hex}` : hex;
93+
})
94+
.join("");
95+
return `#${hex}`;
96+
}
97+
return "#FFFFFF";
98+
});
99+
100+
const themeRgb = computed(() =>
101+
themeHex.value
102+
.replace("#", "")
103+
.match(/.{1,2}/g)
104+
?.map((v) => Number.parseInt(v, 16))
105+
.join(", "),
106+
);
107+
108+
const siteConfig = useSiteConfig();
109+
const siteName = computed(() => siteConfig.name);
110+
</script>

0 commit comments

Comments
 (0)