kmcom-nuxt-layers 2.3.1 → 2.3.2
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/layers/content/app/composables/createPortfolioComposables.ts +2 -1
- package/layers/content/app/composables/useBlogPosts.ts +2 -1
- package/layers/content/app/composables/useGalleryItems.ts +14 -8
- package/layers/content/app/composables/usePortfolioItems.ts +18 -10
- package/layers/feeds/server/utils/content-adapter.ts +6 -0
- package/layers/forms/app/components/Form/Contact.vue +4 -13
- package/layers/forms/app/utils/contact-schema.ts +9 -0
- package/layers/forms/server/api/contact.post.ts +23 -6
- package/layers/forms/server/api/forms/status.get.ts +1 -3
- package/layers/layout/app/utils/gridPlacementStyle.ts +10 -9
- package/layers/routing/server/api/feature-flags.get.ts +6 -0
- package/layers/scroll/app/plugins/locomotive-scroll.client.ts +6 -1
- package/layers/starter/app/app.config.ts +10 -0
- package/layers/starter/app/components/StarterHome.vue +34 -25
- package/layers/theme/app/components/ThemePicker/Menu.vue +8 -2
- package/layers/theme/nuxt.config.ts +1 -1
- package/layers/theme/server/plugins/theme-fouc.ts +2 -57
- package/layers/theme/server/utils/fouc-config.ts +85 -0
- package/package.json +1 -1
- package/layers/content/app/composables/useCollectionItems.ts +0 -28
|
@@ -20,7 +20,8 @@ export function createPortfolioComposables(collectionName: keyof Collections) {
|
|
|
20
20
|
function useItems(options: PortfolioQueryOptions = {}) {
|
|
21
21
|
const { featured, tags, limit } = options
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
const key = `${collectionName}-items:${featured ?? ''}:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
|
|
24
|
+
return useContentData(key, async () => {
|
|
24
25
|
let items: CollectionItem[] = (await queryCollection(collectionName).all()).sort(
|
|
25
26
|
(a, b) => itemYear(b) - itemYear(a)
|
|
26
27
|
)
|
|
@@ -3,7 +3,8 @@ import type { BlogQueryOptions } from '../types/content'
|
|
|
3
3
|
export function useBlogPosts(options: BlogQueryOptions = {}) {
|
|
4
4
|
const { excludeDrafts = true, tags, limit } = options
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const key = `blog-posts:${excludeDrafts}:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
|
|
7
|
+
return useContentData(key, async () => {
|
|
7
8
|
let posts = (await queryCollection('blog').all()).sort((a, b) =>
|
|
8
9
|
(b.date ?? '').localeCompare(a.date ?? '')
|
|
9
10
|
)
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import type { GalleryQueryOptions } from '../types/content'
|
|
2
|
-
import { useCollectionItems } from './useCollectionItems'
|
|
3
2
|
|
|
4
3
|
export function useGalleryItems(options: GalleryQueryOptions = {}) {
|
|
5
4
|
const { tags, limit } = options
|
|
5
|
+
const key = `gallery-items:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
|
|
6
6
|
|
|
7
|
-
return
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
return useContentData(key, async () => {
|
|
8
|
+
let items = (await queryCollection('gallery').all())
|
|
9
|
+
.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
|
|
10
|
+
|
|
11
|
+
if (tags?.length) {
|
|
12
|
+
items = items.filter((item) => Boolean(item.tags?.some((tag: string) => tags.includes(tag))))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (limit !== undefined) {
|
|
16
|
+
items = items.slice(0, limit)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return items
|
|
14
20
|
})
|
|
15
21
|
}
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import type { PortfolioQueryOptions } from '../types/content'
|
|
2
|
-
import { useCollectionItems } from './useCollectionItems'
|
|
3
2
|
|
|
4
3
|
export function usePortfolioItems(options: PortfolioQueryOptions = {}) {
|
|
5
4
|
const { featured, tags, limit } = options
|
|
5
|
+
const key = `portfolio-items:${featured ?? ''}:${(tags ?? []).slice().sort().join(',')}:${limit ?? ''}`
|
|
6
6
|
|
|
7
|
-
return
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
return useContentData(key, async () => {
|
|
8
|
+
let items = (await queryCollection('portfolio').all())
|
|
9
|
+
.sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
|
|
10
|
+
|
|
11
|
+
if (featured !== undefined) {
|
|
12
|
+
items = items.filter((item) => item.featured === featured)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (tags?.length) {
|
|
16
|
+
items = items.filter((item) => Boolean(item.tags?.some((tag: string) => tags.includes(tag))))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (limit) {
|
|
20
|
+
items = items.slice(0, limit)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return items
|
|
16
24
|
})
|
|
17
25
|
}
|
|
@@ -26,6 +26,8 @@ type FeedCollectionQuery = {
|
|
|
26
26
|
all: () => Promise<FeedSourceItem[]>
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const COLLECTION_NAME_RE = /^[a-z][a-z0-9_-]*$/i
|
|
30
|
+
|
|
29
31
|
const getFeedCollection = queryCollection as unknown as (
|
|
30
32
|
event: H3Event,
|
|
31
33
|
collection: string
|
|
@@ -36,6 +38,10 @@ export async function getContentFeedItems(
|
|
|
36
38
|
collection: string = 'blog',
|
|
37
39
|
limit: number = 30
|
|
38
40
|
): Promise<FeedItem[]> {
|
|
41
|
+
if (!COLLECTION_NAME_RE.test(collection)) {
|
|
42
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid collection name' })
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
// queryCollection is keyed by literal collection names, but feed routes accept
|
|
40
46
|
// a runtime collection string; keep the unsafe bridge local here.
|
|
41
47
|
const raw = await getFeedCollection(event, collection).all()
|
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
3
|
-
import { z } from 'zod'
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import { contactSchema, type ContactData } from '../../utils/contact-schema'
|
|
6
5
|
|
|
7
6
|
const emit = defineEmits<{
|
|
8
|
-
submit: [data:
|
|
7
|
+
submit: [data: ContactData]
|
|
9
8
|
}>()
|
|
10
9
|
|
|
11
|
-
const schema =
|
|
12
|
-
name: fieldConfigs.name.validation.pipe(
|
|
13
|
-
z.string().min(3, 'Name must be at least 3 characters')
|
|
14
|
-
),
|
|
15
|
-
email: fieldConfigs.email.validation,
|
|
16
|
-
message: fieldConfigs.textarea.validation.pipe(
|
|
17
|
-
z.string().min(8, 'Message must be at least 8 characters')
|
|
18
|
-
),
|
|
19
|
-
})
|
|
10
|
+
const schema = contactSchema
|
|
20
11
|
|
|
21
|
-
type FormState =
|
|
12
|
+
type FormState = ContactData
|
|
22
13
|
|
|
23
14
|
const state = reactive({ name: '', email: '', message: '' })
|
|
24
15
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const contactSchema = z.object({
|
|
4
|
+
name: z.string().min(3, 'Name must be at least 3 characters'),
|
|
5
|
+
email: z.string().email('Please enter a valid email'),
|
|
6
|
+
message: z.string().min(8, 'Message must be at least 8 characters'),
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export type ContactData = z.infer<typeof contactSchema>
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { sendContactEmail } from '#layers/mailer/server/utils/email'
|
|
2
2
|
import { mailerLayerHooks } from '#layers/mailer/server/utils/hooks'
|
|
3
|
-
import { z } from 'zod'
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import { contactSchema } from '../../app/utils/contact-schema'
|
|
5
|
+
|
|
6
|
+
type RateLimitRecord = { count: number; resetAt: number }
|
|
7
|
+
const rateLimitStore = new Map<string, RateLimitRecord>()
|
|
8
|
+
const RATE_LIMIT_MAX = 5
|
|
9
|
+
const RATE_LIMIT_WINDOW_MS = 60_000
|
|
10
|
+
|
|
11
|
+
function checkRateLimit(ip: string): boolean {
|
|
12
|
+
const now = Date.now()
|
|
13
|
+
const record = rateLimitStore.get(ip)
|
|
14
|
+
if (!record || now > record.resetAt) {
|
|
15
|
+
rateLimitStore.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
if (record.count >= RATE_LIMIT_MAX) return false
|
|
19
|
+
record.count++
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
10
22
|
|
|
11
23
|
export default defineEventHandler(async (event) => {
|
|
24
|
+
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
|
25
|
+
if (!checkRateLimit(ip)) {
|
|
26
|
+
throw createError({ statusCode: 429, statusMessage: 'Too many requests. Please try again later.' })
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
const body = await readBody(event)
|
|
13
30
|
const result = contactSchema.safeParse(body)
|
|
14
31
|
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { useMailerConfig } from '#layers/mailer/server/utils/config'
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(() => {
|
|
4
|
-
const { resendApiKey
|
|
4
|
+
const { resendApiKey } = useMailerConfig()
|
|
5
5
|
|
|
6
6
|
return {
|
|
7
7
|
configured: Boolean(resendApiKey),
|
|
8
|
-
emailFrom: emailFrom || null,
|
|
9
|
-
emailTo: emailTo || null,
|
|
10
8
|
}
|
|
11
9
|
})
|
|
@@ -164,16 +164,17 @@ export function buildGridPlacementStyle(input: GridPlacementInput): PlacementSty
|
|
|
164
164
|
style['--_re'] = String(rowSpan)
|
|
165
165
|
Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
|
|
166
166
|
Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
|
|
167
|
-
|
|
168
|
-
style['--_cs'] = String(colStart ?? 'auto')
|
|
169
|
-
style['--_ce'] = String(colSpan)
|
|
170
|
-
style['--_rs'] = String(rowStart ?? 'auto')
|
|
171
|
-
style['--_re'] = String(rowSpan)
|
|
172
|
-
Object.assign(style, resolveResponsivePlacementVars(input.colStart, 'cs'))
|
|
173
|
-
Object.assign(style, resolveResponsivePlacementVars(input.colSpan, 'ce'))
|
|
174
|
-
Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
|
|
175
|
-
Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
|
|
167
|
+
return style
|
|
176
168
|
}
|
|
169
|
+
|
|
170
|
+
style['--_cs'] = String(colStart ?? 'auto')
|
|
171
|
+
style['--_ce'] = String(colSpan)
|
|
172
|
+
style['--_rs'] = String(rowStart ?? 'auto')
|
|
173
|
+
style['--_re'] = String(rowSpan)
|
|
174
|
+
Object.assign(style, resolveResponsivePlacementVars(input.colStart, 'cs'))
|
|
175
|
+
Object.assign(style, resolveResponsivePlacementVars(input.colSpan, 'ce'))
|
|
176
|
+
Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
|
|
177
|
+
Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
Object.assign(style, resolveAlignmentStyles(input.align, input.justify))
|
|
@@ -47,7 +47,12 @@ export default defineNuxtPlugin({
|
|
|
47
47
|
|
|
48
48
|
if (smoothScroll === true) {
|
|
49
49
|
nuxtApp.hook('app:mounted', () => nextTick(init))
|
|
50
|
-
nuxtApp.hook('page:finish', () =>
|
|
50
|
+
nuxtApp.hook('page:finish', () =>
|
|
51
|
+
nextTick(() => {
|
|
52
|
+
instance.value?.scrollTo(0, { immediate: true })
|
|
53
|
+
ScrollTrigger.refresh()
|
|
54
|
+
})
|
|
55
|
+
)
|
|
51
56
|
} else if (Array.isArray(smoothScroll)) {
|
|
52
57
|
addRouteMiddleware((to, from) => {
|
|
53
58
|
if (smoothScroll.includes(to.path)) {
|
|
@@ -11,36 +11,45 @@ gsap.registerPlugin(ScrollTrigger)
|
|
|
11
11
|
const heroRef = ref<HTMLElement | null>(null)
|
|
12
12
|
const sectionsRef = ref<HTMLElement | null>(null)
|
|
13
13
|
|
|
14
|
+
let ctx: ReturnType<typeof gsap.context> | null = null
|
|
15
|
+
|
|
14
16
|
onMounted(() => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
ctx = gsap.context(() => {
|
|
18
|
+
if (heroRef.value) {
|
|
19
|
+
gsap.from(Array.from(heroRef.value.children), {
|
|
20
|
+
y: 24,
|
|
21
|
+
opacity: 0,
|
|
22
|
+
duration: 0.7,
|
|
23
|
+
stagger: 0.1,
|
|
24
|
+
ease: 'power3.out',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (sectionsRef.value) {
|
|
29
|
+
gsap.from(sectionsRef.value.querySelectorAll('.layer-card'), {
|
|
30
|
+
scrollTrigger: {
|
|
31
|
+
trigger: sectionsRef.value,
|
|
32
|
+
start: 'top 85%',
|
|
33
|
+
toggleActions: 'play none none none',
|
|
34
|
+
},
|
|
35
|
+
y: 32,
|
|
36
|
+
opacity: 0,
|
|
37
|
+
scale: 0.97,
|
|
38
|
+
duration: 0.5,
|
|
39
|
+
stagger: { amount: 0.5 },
|
|
40
|
+
ease: 'power3.out',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
})
|
|
24
45
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
trigger: sectionsRef.value,
|
|
29
|
-
start: 'top 85%',
|
|
30
|
-
toggleActions: 'play none none none',
|
|
31
|
-
},
|
|
32
|
-
y: 32,
|
|
33
|
-
opacity: 0,
|
|
34
|
-
scale: 0.97,
|
|
35
|
-
duration: 0.5,
|
|
36
|
-
stagger: { amount: 0.5 },
|
|
37
|
-
ease: 'power3.out',
|
|
38
|
-
})
|
|
39
|
-
}
|
|
46
|
+
onUnmounted(() => {
|
|
47
|
+
ctx?.revert()
|
|
48
|
+
ctx = null
|
|
40
49
|
})
|
|
41
50
|
|
|
42
51
|
const appConfig = useAppConfig()
|
|
43
|
-
const activeLayers = appConfig.layers
|
|
52
|
+
const activeLayers = appConfig.layers ?? {}
|
|
44
53
|
|
|
45
54
|
type LayerDef = {
|
|
46
55
|
name: string
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useThemePreferenceModels } from '../../composables/useThemePreferenceModels'
|
|
3
3
|
|
|
4
|
-
const {
|
|
5
|
-
|
|
4
|
+
const {
|
|
5
|
+
contrastOverride,
|
|
6
|
+
motionOverride,
|
|
7
|
+
transparencyOverride,
|
|
8
|
+
contrastModel,
|
|
9
|
+
motionModel,
|
|
10
|
+
transparencyModel,
|
|
11
|
+
} = useThemePreferenceModels()
|
|
6
12
|
</script>
|
|
7
13
|
|
|
8
14
|
<template>
|
|
@@ -1,67 +1,12 @@
|
|
|
1
1
|
import { buildAccentCSS } from '../utils/accent-css'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Nitro render hook — prevents FOUC for all theme preferences by injecting:
|
|
5
|
-
*
|
|
6
|
-
* 1. A `<style>` tag with CSS rules mapping each `[data-theme-colour='X']` to
|
|
7
|
-
* the correct `--ui-color-primary-*`, `--ui-color-secondary-*`, and
|
|
8
|
-
* `--ui-color-info-*` values. Each accent selects a coordinated three-colour
|
|
9
|
-
* palette so primary, secondary, and accent (info) all change together.
|
|
10
|
-
*
|
|
11
|
-
* 2. A blocking `<script>` that reads localStorage and restores all
|
|
12
|
-
* `data-theme-*` attributes on `<html>` before first paint:
|
|
13
|
-
* - `data-theme-colour` from `theme-colour` (defaults to 'blue')
|
|
14
|
-
* - `data-theme-contrast` from `theme-contrast`
|
|
15
|
-
* - `data-theme-motion` from `theme-motion`
|
|
16
|
-
* - `data-theme-transparency` from `theme-transparency`
|
|
17
|
-
* - `data-theme-mode` from `theme-mode` (raw string, not JSON — set by Nuxt Color Mode)
|
|
18
|
-
*
|
|
19
|
-
* Our script runs before CSS link tags (via head.unshift), so setting data-theme-mode
|
|
20
|
-
* here prevents the dark-mode FOUC that would otherwise occur when Nuxt Color Mode's
|
|
21
|
-
* script runs after stylesheets have downloaded. Nuxt Color Mode's script will then
|
|
22
|
-
* set the same value again — this is idempotent and safe.
|
|
23
|
-
*/
|
|
2
|
+
import { buildThemeInitScript } from '../utils/fouc-config'
|
|
24
3
|
|
|
25
4
|
const accentCSS = buildAccentCSS()
|
|
26
5
|
|
|
27
|
-
// Blocking init script — restores data-* attributes from localStorage before first paint.
|
|
28
|
-
// Written as a self-invoking function to avoid polluting the global scope.
|
|
29
|
-
// JSON.parse handles the quoted string that useLocalStorage writes.
|
|
30
|
-
// Note: theme-mode is stored as a raw string by Nuxt Color Mode (no JSON.stringify).
|
|
31
|
-
function buildInitScript(defaultAccent: string) {
|
|
32
|
-
return `(function(){
|
|
33
|
-
try{
|
|
34
|
-
var h=document.documentElement;
|
|
35
|
-
var c=localStorage.getItem('theme-colour');
|
|
36
|
-
h.setAttribute('data-theme-colour',c?JSON.parse(c):'${defaultAccent}');
|
|
37
|
-
var ct=localStorage.getItem('theme-contrast');
|
|
38
|
-
var ctv=ct?JSON.parse(ct):'system';
|
|
39
|
-
if(ctv==='on'){h.setAttribute('data-theme-contrast','high')}
|
|
40
|
-
else if(ctv==='off'){h.setAttribute('data-theme-contrast','standard')}
|
|
41
|
-
else{h.setAttribute('data-theme-contrast',(window.matchMedia&&window.matchMedia('(prefers-contrast:more)').matches)?'high':'standard')}
|
|
42
|
-
var m=localStorage.getItem('theme-motion');
|
|
43
|
-
var mv=m?JSON.parse(m):'system';
|
|
44
|
-
if(mv==='on'){h.setAttribute('data-theme-motion','reduced')}
|
|
45
|
-
else if(mv==='off'){h.setAttribute('data-theme-motion','full')}
|
|
46
|
-
else{h.setAttribute('data-theme-motion',(window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches)?'reduced':'full')}
|
|
47
|
-
var t=localStorage.getItem('theme-transparency');
|
|
48
|
-
var tv=t?JSON.parse(t):'system';
|
|
49
|
-
if(tv==='on'){h.setAttribute('data-theme-transparency','reduced')}
|
|
50
|
-
else if(tv==='off'){h.setAttribute('data-theme-transparency','full')}
|
|
51
|
-
else{h.setAttribute('data-theme-transparency',(window.matchMedia&&window.matchMedia('(prefers-reduced-transparency:reduce)').matches)?'reduced':'full')}
|
|
52
|
-
var dm=localStorage.getItem('theme-mode');
|
|
53
|
-
var dmv=dm||'system';
|
|
54
|
-
if(dmv==='dark'){h.setAttribute('data-theme-mode','dark')}
|
|
55
|
-
else if(dmv==='light'){h.setAttribute('data-theme-mode','light')}
|
|
56
|
-
else{h.setAttribute('data-theme-mode',(window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches)?'dark':'light')}
|
|
57
|
-
}catch(e){}
|
|
58
|
-
})()`.replace(/\n\s*/g, '')
|
|
59
|
-
}
|
|
60
|
-
|
|
61
6
|
export default defineNitroPlugin((nitroApp) => {
|
|
62
7
|
nitroApp.hooks.hook('render:html', (html, { event }) => {
|
|
63
8
|
const config = useRuntimeConfig(event)
|
|
64
|
-
const initScript =
|
|
9
|
+
const initScript = buildThemeInitScript(config.public.themeDefaultAccent || 'blue')
|
|
65
10
|
html.head.unshift(
|
|
66
11
|
`<style id="theme-accent-css">${accentCSS}</style><script>${initScript}</script>`
|
|
67
12
|
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
type TriStatePreference = {
|
|
2
|
+
varName: string
|
|
3
|
+
storageKey: string
|
|
4
|
+
attribute: string
|
|
5
|
+
jsonParsed: boolean
|
|
6
|
+
conditionA: string
|
|
7
|
+
valueA: string
|
|
8
|
+
conditionB: string
|
|
9
|
+
valueB: string
|
|
10
|
+
mediaQuery: string
|
|
11
|
+
mediaTrue: string
|
|
12
|
+
mediaFalse: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Each entry produces one tri-state block in the FOUC init script.
|
|
16
|
+
// Adding a new theme preference = adding one entry here.
|
|
17
|
+
const THEME_PREFERENCES: TriStatePreference[] = [
|
|
18
|
+
{
|
|
19
|
+
varName: 'ct',
|
|
20
|
+
storageKey: 'theme-contrast',
|
|
21
|
+
attribute: 'data-theme-contrast',
|
|
22
|
+
jsonParsed: true,
|
|
23
|
+
conditionA: 'on', valueA: 'high',
|
|
24
|
+
conditionB: 'off', valueB: 'standard',
|
|
25
|
+
mediaQuery: '(prefers-contrast:more)',
|
|
26
|
+
mediaTrue: 'high', mediaFalse: 'standard',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
varName: 'm',
|
|
30
|
+
storageKey: 'theme-motion',
|
|
31
|
+
attribute: 'data-theme-motion',
|
|
32
|
+
jsonParsed: true,
|
|
33
|
+
conditionA: 'on', valueA: 'reduced',
|
|
34
|
+
conditionB: 'off', valueB: 'full',
|
|
35
|
+
mediaQuery: '(prefers-reduced-motion:reduce)',
|
|
36
|
+
mediaTrue: 'reduced', mediaFalse: 'full',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
varName: 't',
|
|
40
|
+
storageKey: 'theme-transparency',
|
|
41
|
+
attribute: 'data-theme-transparency',
|
|
42
|
+
jsonParsed: true,
|
|
43
|
+
conditionA: 'on', valueA: 'reduced',
|
|
44
|
+
conditionB: 'off', valueB: 'full',
|
|
45
|
+
mediaQuery: '(prefers-reduced-transparency:reduce)',
|
|
46
|
+
mediaTrue: 'reduced', mediaFalse: 'full',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
varName: 'dm',
|
|
50
|
+
storageKey: 'theme-mode',
|
|
51
|
+
attribute: 'data-theme-mode',
|
|
52
|
+
jsonParsed: false,
|
|
53
|
+
conditionA: 'dark', valueA: 'dark',
|
|
54
|
+
conditionB: 'light', valueB: 'light',
|
|
55
|
+
mediaQuery: '(prefers-color-scheme:dark)',
|
|
56
|
+
mediaTrue: 'dark', mediaFalse: 'light',
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
function buildPrefFragment(p: TriStatePreference): string {
|
|
61
|
+
const readValue = p.jsonParsed
|
|
62
|
+
? `${p.varName}?JSON.parse(${p.varName}):'system'`
|
|
63
|
+
: `${p.varName}||'system'`
|
|
64
|
+
return (
|
|
65
|
+
`var ${p.varName}=localStorage.getItem('${p.storageKey}');` +
|
|
66
|
+
`var ${p.varName}v=${readValue};` +
|
|
67
|
+
`if(${p.varName}v==='${p.conditionA}'){h.setAttribute('${p.attribute}','${p.valueA}')}` +
|
|
68
|
+
`else if(${p.varName}v==='${p.conditionB}'){h.setAttribute('${p.attribute}','${p.valueB}')}` +
|
|
69
|
+
`else{h.setAttribute('${p.attribute}',(window.matchMedia&&window.matchMedia('${p.mediaQuery}').matches)?'${p.mediaTrue}':'${p.mediaFalse}')}`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildThemeInitScript(defaultAccent: string): string {
|
|
74
|
+
const colourBlock =
|
|
75
|
+
`var c=localStorage.getItem('theme-colour');` +
|
|
76
|
+
`h.setAttribute('data-theme-colour',c?JSON.parse(c):'${defaultAccent}');`
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
'(function(){try{' +
|
|
80
|
+
'var h=document.documentElement;' +
|
|
81
|
+
colourBlock +
|
|
82
|
+
THEME_PREFERENCES.map(buildPrefFragment).join('') +
|
|
83
|
+
'}catch(e){}})()'
|
|
84
|
+
)
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { Collections } from '@nuxt/content'
|
|
2
|
-
|
|
3
|
-
type CollectionItem<TCollection extends keyof Collections> = Collections[TCollection]
|
|
4
|
-
|
|
5
|
-
export function useCollectionItems<TCollection extends keyof Collections>(args: {
|
|
6
|
-
key: string
|
|
7
|
-
collection: TCollection
|
|
8
|
-
sort: (a: CollectionItem<TCollection>, b: CollectionItem<TCollection>) => number
|
|
9
|
-
options?: { limit?: number | undefined }
|
|
10
|
-
filter?: (item: CollectionItem<TCollection>) => boolean
|
|
11
|
-
}) {
|
|
12
|
-
const { key, collection, sort, options = {}, filter } = args
|
|
13
|
-
|
|
14
|
-
return useContentData(key, async () => {
|
|
15
|
-
let items = (await queryCollection(collection).all()) as CollectionItem<TCollection>[]
|
|
16
|
-
items = items.sort(sort)
|
|
17
|
-
|
|
18
|
-
if (filter) {
|
|
19
|
-
items = items.filter(filter)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (options.limit !== undefined) {
|
|
23
|
-
items = items.slice(0, options.limit)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return items
|
|
27
|
-
})
|
|
28
|
-
}
|