@commonpub/layer 0.3.38 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CookieConsent.vue +91 -0
- package/components/SiteLogo.vue +41 -2
- package/composables/useCookieConsent.ts +117 -0
- package/composables/useTheme.ts +45 -24
- package/layouts/admin.vue +1 -0
- package/layouts/default.vue +4 -0
- package/nuxt.config.ts +17 -0
- package/package.json +7 -6
- package/pages/admin/theme.vue +500 -0
- package/pages/cookies.vue +186 -0
- package/pages/privacy.vue +6 -5
- package/pages/settings/appearance.vue +143 -38
- package/plugins/theme.ts +32 -0
- package/server/api/admin/settings.get.ts +7 -3
- package/server/api/admin/settings.put.ts +6 -1
- package/server/api/auth/delete-user.post.ts +1 -1
- package/server/middleware/theme.ts +34 -0
- package/server/utils/instanceTheme.ts +67 -0
- package/theme/agora-dark.css +353 -0
- package/theme/agora.css +456 -0
- package/utils/themeConfig.ts +39 -0
package/pages/privacy.vue
CHANGED
|
@@ -62,13 +62,14 @@ const { federation: federationEnabled } = useFeatures();
|
|
|
62
62
|
</section>
|
|
63
63
|
|
|
64
64
|
<section class="cpub-legal-section">
|
|
65
|
-
<h2>5. Cookies
|
|
66
|
-
<p>We use
|
|
65
|
+
<h2>5. Cookies</h2>
|
|
66
|
+
<p>We use a small number of cookies to provide and improve the service:</p>
|
|
67
67
|
<ul>
|
|
68
|
-
<li><strong>Session cookie</strong> (<code>better-auth.session_token</code>):
|
|
69
|
-
<li><strong>
|
|
68
|
+
<li><strong>Session cookie</strong> (<code>better-auth.session_token</code>): strictly necessary — authenticates your login session. HttpOnly, secure, 7-day expiry.</li>
|
|
69
|
+
<li><strong>Consent cookie</strong> (<code>cpub-consent</code>): strictly necessary — stores your cookie consent choice.</li>
|
|
70
|
+
<li><strong>Color scheme</strong> (<code>cpub-color-scheme</code>): functional — remembers your light/dark mode preference. Set only with your consent.</li>
|
|
70
71
|
</ul>
|
|
71
|
-
<p>We do not use any
|
|
72
|
+
<p>We do not use any advertising or tracking cookies. Your instance operator may add analytics cookies — these require your explicit consent. For the full list of cookies and to manage your preferences, visit our <NuxtLink to="/cookies">Cookie Policy</NuxtLink>.</p>
|
|
72
73
|
</section>
|
|
73
74
|
|
|
74
75
|
<section v-if="federationEnabled" class="cpub-legal-section">
|
|
@@ -1,32 +1,112 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
definePageMeta({ middleware: 'auth' });
|
|
3
3
|
|
|
4
|
-
const { themeId
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
const { themeId, isDark, instanceDefault, setDarkMode } = useTheme();
|
|
5
|
+
|
|
6
|
+
const familyLabels: Record<string, string> = {
|
|
7
|
+
base: 'Classic',
|
|
8
|
+
dark: 'Classic',
|
|
9
|
+
generics: 'Generics',
|
|
10
|
+
agora: 'Agora',
|
|
11
|
+
'agora-dark': 'Agora',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const currentFamily = computed(() => familyLabels[instanceDefault.value] ?? 'Classic');
|
|
15
|
+
|
|
16
|
+
function previewColors(dark: boolean): { bg: string; surface: string; accent: string; text: string; border: string } {
|
|
17
|
+
// Resolve what theme ID would be for this mode
|
|
18
|
+
const families: Record<string, { light: string; dark: string }> = {
|
|
19
|
+
classic: { light: 'base', dark: 'dark' },
|
|
20
|
+
agora: { light: 'agora', dark: 'agora-dark' },
|
|
21
|
+
generics: { light: 'generics', dark: 'generics' },
|
|
22
|
+
};
|
|
23
|
+
const familyMap: Record<string, string> = {
|
|
24
|
+
base: 'classic', dark: 'classic', generics: 'generics',
|
|
25
|
+
agora: 'agora', 'agora-dark': 'agora',
|
|
26
|
+
};
|
|
27
|
+
const family = familyMap[instanceDefault.value] ?? 'classic';
|
|
28
|
+
const id = dark ? families[family]!.dark : families[family]!.light;
|
|
29
|
+
|
|
30
|
+
const palette: Record<string, { bg: string; surface: string; accent: string; text: string; border: string }> = {
|
|
31
|
+
base: { bg: '#fafaf9', surface: '#ffffff', accent: '#5b9cf6', text: '#1a1a1a', border: '#1a1a1a' },
|
|
32
|
+
dark: { bg: '#111111', surface: '#1a1a1a', accent: '#5b9cf6', text: '#e5e5e3', border: '#444440' },
|
|
33
|
+
generics: { bg: '#0c0c0b', surface: '#141413', accent: '#5b9cf6', text: '#d8d5cf', border: '#272725' },
|
|
34
|
+
agora: { bg: '#f7f4ed', surface: '#faf8f3', accent: '#3d8b5e', text: '#1a1a1a', border: '#1a1a1a' },
|
|
35
|
+
'agora-dark': { bg: '#0d1a12', surface: '#141f17', accent: '#4aa06e', text: '#e8e8e2', border: '#3a4f40' },
|
|
36
|
+
};
|
|
37
|
+
return palette[id] ?? palette.base!;
|
|
38
|
+
}
|
|
10
39
|
</script>
|
|
11
40
|
|
|
12
41
|
<template>
|
|
13
42
|
<div>
|
|
14
43
|
<h2 class="cpub-section-title-lg">Appearance</h2>
|
|
15
44
|
|
|
16
|
-
<
|
|
45
|
+
<p class="cpub-appearance-note">
|
|
46
|
+
Your instance uses the <strong>{{ currentFamily }}</strong> theme.
|
|
47
|
+
Choose your preferred color scheme.
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<div class="cpub-scheme-grid">
|
|
51
|
+
<button
|
|
52
|
+
class="cpub-scheme-card"
|
|
53
|
+
:class="{ active: !isDark }"
|
|
54
|
+
@click="setDarkMode(false)"
|
|
55
|
+
>
|
|
56
|
+
<div
|
|
57
|
+
class="cpub-scheme-preview"
|
|
58
|
+
:style="{
|
|
59
|
+
backgroundColor: previewColors(false).bg,
|
|
60
|
+
borderColor: previewColors(false).border,
|
|
61
|
+
}"
|
|
62
|
+
>
|
|
63
|
+
<div
|
|
64
|
+
class="cpub-scheme-preview-card"
|
|
65
|
+
:style="{
|
|
66
|
+
backgroundColor: previewColors(false).surface,
|
|
67
|
+
borderColor: previewColors(false).border,
|
|
68
|
+
boxShadow: `3px 3px 0 ${previewColors(false).border}`,
|
|
69
|
+
}"
|
|
70
|
+
>
|
|
71
|
+
<div class="cpub-preview-heading" :style="{ backgroundColor: previewColors(false).text, opacity: 0.8 }"></div>
|
|
72
|
+
<div class="cpub-preview-line" :style="{ backgroundColor: previewColors(false).text, opacity: 0.3 }"></div>
|
|
73
|
+
<div class="cpub-preview-line short" :style="{ backgroundColor: previewColors(false).text, opacity: 0.2 }"></div>
|
|
74
|
+
<div class="cpub-preview-btn" :style="{ backgroundColor: previewColors(false).accent }"></div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="cpub-scheme-info">
|
|
78
|
+
<span class="cpub-scheme-name">Light</span>
|
|
79
|
+
</div>
|
|
80
|
+
</button>
|
|
81
|
+
|
|
17
82
|
<button
|
|
18
|
-
|
|
19
|
-
:
|
|
20
|
-
|
|
21
|
-
:class="{ active: theme === t.id }"
|
|
22
|
-
@click="setTheme(t.id)"
|
|
83
|
+
class="cpub-scheme-card"
|
|
84
|
+
:class="{ active: isDark }"
|
|
85
|
+
@click="setDarkMode(true)"
|
|
23
86
|
>
|
|
24
|
-
<div
|
|
25
|
-
|
|
87
|
+
<div
|
|
88
|
+
class="cpub-scheme-preview"
|
|
89
|
+
:style="{
|
|
90
|
+
backgroundColor: previewColors(true).bg,
|
|
91
|
+
borderColor: previewColors(true).border,
|
|
92
|
+
}"
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
class="cpub-scheme-preview-card"
|
|
96
|
+
:style="{
|
|
97
|
+
backgroundColor: previewColors(true).surface,
|
|
98
|
+
borderColor: previewColors(true).border,
|
|
99
|
+
boxShadow: `3px 3px 0 ${previewColors(true).border}`,
|
|
100
|
+
}"
|
|
101
|
+
>
|
|
102
|
+
<div class="cpub-preview-heading" :style="{ backgroundColor: previewColors(true).text, opacity: 0.8 }"></div>
|
|
103
|
+
<div class="cpub-preview-line" :style="{ backgroundColor: previewColors(true).text, opacity: 0.3 }"></div>
|
|
104
|
+
<div class="cpub-preview-line short" :style="{ backgroundColor: previewColors(true).text, opacity: 0.2 }"></div>
|
|
105
|
+
<div class="cpub-preview-btn" :style="{ backgroundColor: previewColors(true).accent }"></div>
|
|
106
|
+
</div>
|
|
26
107
|
</div>
|
|
27
|
-
<div class="cpub-
|
|
28
|
-
<span class="cpub-
|
|
29
|
-
<span class="cpub-theme-desc">{{ t.desc }}</span>
|
|
108
|
+
<div class="cpub-scheme-info">
|
|
109
|
+
<span class="cpub-scheme-name">Dark</span>
|
|
30
110
|
</div>
|
|
31
111
|
</button>
|
|
32
112
|
</div>
|
|
@@ -34,48 +114,73 @@ const themes = [
|
|
|
34
114
|
</template>
|
|
35
115
|
|
|
36
116
|
<style scoped>
|
|
37
|
-
.cpub-
|
|
117
|
+
.cpub-appearance-note {
|
|
118
|
+
font-size: var(--text-sm);
|
|
119
|
+
color: var(--text-dim);
|
|
120
|
+
margin-bottom: var(--space-4);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.cpub-scheme-grid {
|
|
38
124
|
display: grid;
|
|
39
|
-
grid-template-columns: repeat(
|
|
40
|
-
gap:
|
|
125
|
+
grid-template-columns: repeat(2, 1fr);
|
|
126
|
+
gap: var(--space-3);
|
|
127
|
+
max-width: 440px;
|
|
41
128
|
}
|
|
42
129
|
|
|
43
|
-
.cpub-
|
|
130
|
+
.cpub-scheme-card {
|
|
44
131
|
background: var(--surface);
|
|
45
132
|
border: var(--border-width-default) solid var(--border2);
|
|
46
|
-
padding:
|
|
133
|
+
padding: 0;
|
|
47
134
|
cursor: pointer;
|
|
48
135
|
text-align: left;
|
|
136
|
+
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
|
|
49
137
|
}
|
|
50
138
|
|
|
51
|
-
.cpub-
|
|
139
|
+
.cpub-scheme-card.active {
|
|
52
140
|
border-color: var(--accent);
|
|
53
141
|
box-shadow: var(--shadow-accent);
|
|
54
142
|
}
|
|
55
143
|
|
|
56
|
-
.cpub-
|
|
144
|
+
.cpub-scheme-card:hover {
|
|
57
145
|
border-color: var(--border);
|
|
146
|
+
transform: translate(-1px, -1px);
|
|
147
|
+
box-shadow: var(--shadow-sm);
|
|
58
148
|
}
|
|
59
149
|
|
|
60
|
-
.cpub-
|
|
61
|
-
height:
|
|
62
|
-
|
|
63
|
-
border: var(--border-width-default) solid var(--border2);
|
|
64
|
-
|
|
150
|
+
.cpub-scheme-preview {
|
|
151
|
+
height: 80px;
|
|
152
|
+
padding: var(--space-3);
|
|
153
|
+
border-bottom: var(--border-width-default) solid var(--border2);
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: center;
|
|
65
157
|
}
|
|
66
158
|
|
|
67
|
-
.cpub-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
159
|
+
.cpub-scheme-preview-card {
|
|
160
|
+
width: 75%;
|
|
161
|
+
padding: var(--space-2);
|
|
162
|
+
border-width: 2px;
|
|
163
|
+
border-style: solid;
|
|
164
|
+
display: flex;
|
|
165
|
+
flex-direction: column;
|
|
166
|
+
gap: 3px;
|
|
71
167
|
}
|
|
72
168
|
|
|
73
|
-
.cpub-
|
|
74
|
-
|
|
75
|
-
|
|
169
|
+
.cpub-preview-heading { height: 5px; width: 55%; }
|
|
170
|
+
.cpub-preview-line { height: 3px; width: 85%; }
|
|
171
|
+
.cpub-preview-line.short { width: 45%; }
|
|
172
|
+
.cpub-preview-btn { height: 12px; width: 35%; margin-top: 3px; }
|
|
173
|
+
|
|
174
|
+
.cpub-scheme-info {
|
|
175
|
+
padding: var(--space-3);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.cpub-scheme-name {
|
|
179
|
+
font-size: var(--text-sm);
|
|
180
|
+
font-weight: var(--font-weight-semibold);
|
|
76
181
|
}
|
|
77
182
|
|
|
78
|
-
@media (max-width:
|
|
79
|
-
.cpub-
|
|
183
|
+
@media (max-width: 480px) {
|
|
184
|
+
.cpub-scheme-grid { grid-template-columns: 1fr; }
|
|
80
185
|
}
|
|
81
186
|
</style>
|
package/plugins/theme.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Theme plugin — resolves theme on server (zero flash) and hydrates on client.
|
|
2
|
+
//
|
|
3
|
+
// The admin picks the instance theme (family + default mode).
|
|
4
|
+
// Users only toggle light/dark. Server middleware resolves the final theme ID.
|
|
5
|
+
// This plugin reads that resolved value and sets data-theme on <html>.
|
|
6
|
+
|
|
7
|
+
export default defineNuxtPlugin(() => {
|
|
8
|
+
const themeId = useState<string>('cpub-theme', () => 'base');
|
|
9
|
+
const instanceTheme = useState<string>('cpub-instance-theme', () => 'base');
|
|
10
|
+
const isDark = useState<boolean>('cpub-dark-mode', () => false);
|
|
11
|
+
|
|
12
|
+
if (import.meta.server) {
|
|
13
|
+
const event = useRequestEvent();
|
|
14
|
+
if (event?.context) {
|
|
15
|
+
themeId.value = event.context.resolvedTheme ?? 'base';
|
|
16
|
+
instanceTheme.value = event.context.instanceTheme ?? 'base';
|
|
17
|
+
isDark.value = event.context.isDarkMode ?? false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (import.meta.client) {
|
|
22
|
+
// One-time migration: clear old localStorage theme (replaced by server-side resolution)
|
|
23
|
+
localStorage.removeItem('cpub-theme');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Set data-theme on <html> during SSR — first paint has the correct theme
|
|
27
|
+
if (themeId.value && themeId.value !== 'base') {
|
|
28
|
+
useHead({
|
|
29
|
+
htmlAttrs: { 'data-theme': themeId.value },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
@@ -6,11 +6,15 @@ export default defineEventHandler(async (event) => {
|
|
|
6
6
|
const db = useDB();
|
|
7
7
|
const config = useConfig();
|
|
8
8
|
|
|
9
|
-
// Get DB-stored settings
|
|
9
|
+
// Get DB-stored settings (returns Map — convert to plain object)
|
|
10
10
|
const dbSettings = await getInstanceSettings(db);
|
|
11
|
+
const stored: Record<string, unknown> = {};
|
|
12
|
+
for (const [key, value] of dbSettings) {
|
|
13
|
+
stored[key] = value;
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
// Merge with running config defaults so the UI shows actual values
|
|
13
|
-
const defaults: Record<string,
|
|
17
|
+
const defaults: Record<string, unknown> = {
|
|
14
18
|
'instance.name': config.instance.name,
|
|
15
19
|
'instance.description': config.instance.description,
|
|
16
20
|
'instance.registrationOpen': 'true',
|
|
@@ -18,5 +22,5 @@ export default defineEventHandler(async (event) => {
|
|
|
18
22
|
'instance.contactEmail': config.instance.contactEmail ?? '',
|
|
19
23
|
};
|
|
20
24
|
|
|
21
|
-
return { ...defaults, ...
|
|
25
|
+
return { ...defaults, ...stored };
|
|
22
26
|
});
|
|
@@ -7,5 +7,10 @@ export default defineEventHandler(async (event): Promise<void> => {
|
|
|
7
7
|
const db = useDB();
|
|
8
8
|
const input = await parseBody(event, adminSettingSchema);
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
await setInstanceSetting(db, input.key, input.value, admin.id);
|
|
11
|
+
|
|
12
|
+
// Invalidate server-side theme cache when the default changes
|
|
13
|
+
if (input.key === 'theme.default') {
|
|
14
|
+
invalidateThemeCache();
|
|
15
|
+
}
|
|
11
16
|
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Resolves the active theme for SSR and injects it into the event context.
|
|
2
|
+
// Runs before page rendering so the theme plugin can set data-theme on <html>.
|
|
3
|
+
//
|
|
4
|
+
// Admin picks instance theme → user only toggles light/dark → server resolves.
|
|
5
|
+
|
|
6
|
+
declare module 'h3' {
|
|
7
|
+
interface H3EventContext {
|
|
8
|
+
/** Final resolved theme ID for this request */
|
|
9
|
+
resolvedTheme: string;
|
|
10
|
+
/** The admin-configured instance default theme */
|
|
11
|
+
instanceTheme: string;
|
|
12
|
+
/** Whether the resolved theme is dark */
|
|
13
|
+
isDarkMode: boolean;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default defineEventHandler(async (event) => {
|
|
18
|
+
// Only resolve theme for SSR page requests — skip API, assets, and internal routes
|
|
19
|
+
const path = getRequestURL(event).pathname;
|
|
20
|
+
if (path.startsWith('/api') || path.startsWith('/_nuxt') || path.startsWith('/__nuxt')) return;
|
|
21
|
+
|
|
22
|
+
const instanceTheme = await getInstanceDefaultTheme();
|
|
23
|
+
event.context.instanceTheme = instanceTheme;
|
|
24
|
+
|
|
25
|
+
// Read user's light/dark preference from cookie
|
|
26
|
+
const schemeCookie = getCookie(event, 'cpub-color-scheme');
|
|
27
|
+
const userScheme = schemeCookie === 'light' || schemeCookie === 'dark'
|
|
28
|
+
? schemeCookie
|
|
29
|
+
: null;
|
|
30
|
+
|
|
31
|
+
const resolved = resolveThemeForUser(instanceTheme, userScheme);
|
|
32
|
+
event.context.resolvedTheme = resolved;
|
|
33
|
+
event.context.isDarkMode = isDefaultDark(resolved);
|
|
34
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Cached instance-level theme resolution for SSR.
|
|
2
|
+
// The admin sets a theme (determines family + default mode).
|
|
3
|
+
// Users only toggle light/dark within that family.
|
|
4
|
+
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import { instanceSettings } from '@commonpub/schema';
|
|
7
|
+
import { THEME_TO_FAMILY, FAMILY_VARIANTS, IS_DARK, VALID_THEME_IDS } from '../../utils/themeConfig';
|
|
8
|
+
|
|
9
|
+
const CACHE_TTL = 60_000; // 1 minute
|
|
10
|
+
|
|
11
|
+
let cached: string | null = null;
|
|
12
|
+
let cacheTime = 0;
|
|
13
|
+
|
|
14
|
+
export async function getInstanceDefaultTheme(): Promise<string> {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (cached !== null && now - cacheTime < CACHE_TTL) return cached;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const db = useDB();
|
|
20
|
+
const [row] = await db
|
|
21
|
+
.select({ value: instanceSettings.value })
|
|
22
|
+
.from(instanceSettings)
|
|
23
|
+
.where(eq(instanceSettings.key, 'theme.default'));
|
|
24
|
+
|
|
25
|
+
if (row?.value && typeof row.value === 'string' && VALID_THEME_IDS.has(row.value)) {
|
|
26
|
+
cached = row.value;
|
|
27
|
+
} else {
|
|
28
|
+
cached = 'base';
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.warn('[theme] Failed to read instance theme from DB, using fallback:', err instanceof Error ? err.message : String(err));
|
|
32
|
+
cached = 'base';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
cacheTime = now;
|
|
36
|
+
return cached;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the final theme ID given the admin's chosen default and the
|
|
41
|
+
* user's light/dark preference.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveThemeForUser(
|
|
44
|
+
adminTheme: string,
|
|
45
|
+
userScheme: 'light' | 'dark' | null,
|
|
46
|
+
): string {
|
|
47
|
+
const family = THEME_TO_FAMILY[adminTheme] ?? 'classic';
|
|
48
|
+
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
49
|
+
|
|
50
|
+
if (userScheme === null) return adminTheme;
|
|
51
|
+
return userScheme === 'dark' ? variants.dark : variants.light;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether a given theme ID is dark */
|
|
55
|
+
export function isDefaultDark(themeId: string): boolean {
|
|
56
|
+
return IS_DARK[themeId] ?? false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Call after admin changes the instance default theme */
|
|
60
|
+
export function invalidateThemeCache(): void {
|
|
61
|
+
cached = null;
|
|
62
|
+
cacheTime = 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isServerValidThemeId(id: string): boolean {
|
|
66
|
+
return VALID_THEME_IDS.has(id);
|
|
67
|
+
}
|