kmcom-nuxt-layers 2.3.1 → 2.5.0
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/README.md +2 -0
- package/docs/ARCHITECTURE.md +4 -0
- package/layers/auth/app/middleware/auth.ts +4 -0
- package/layers/auth/nuxt.config.ts +31 -0
- package/layers/auth/package.json +15 -0
- package/layers/auth/server/routes/auth/github.get.ts +13 -0
- package/layers/auth/tsconfig.json +7 -0
- package/layers/baseline/app/components/BaselineStatus.vue +17 -0
- package/layers/baseline/app/plugins/baseline-status.client.ts +3 -0
- package/layers/baseline/nuxt.config.ts +24 -0
- package/layers/baseline/package.json +14 -0
- package/layers/baseline/tsconfig.json +7 -0
- 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/database/nuxt.config.ts +30 -0
- package/layers/database/package.json +16 -0
- package/layers/database/server/utils/drizzle.ts +5 -0
- package/layers/database/server/utils/sql.ts +7 -0
- package/layers/database/tsconfig.json +7 -0
- 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/metadata/app/composables/useMetadataItem.ts +11 -0
- package/layers/metadata/app/composables/useMetadataSearch.ts +25 -0
- package/layers/metadata/nuxt.config.ts +27 -0
- package/layers/metadata/package.json +12 -0
- package/layers/metadata/server/api/metadata/lookup.get.ts +40 -0
- package/layers/metadata/server/api/metadata/providers.get.ts +7 -0
- package/layers/metadata/server/api/metadata/search.get.ts +17 -0
- package/layers/metadata/server/api/metadata/sync.post.ts +40 -0
- package/layers/metadata/server/utils/metadata/cache.ts +36 -0
- package/layers/metadata/server/utils/metadata/errors.ts +24 -0
- package/layers/metadata/server/utils/metadata/provider-registry.ts +15 -0
- package/layers/metadata/server/utils/metadata/search.ts +42 -0
- package/layers/metadata/tsconfig.json +7 -0
- package/layers/metadata-comicvine/nuxt.config.ts +36 -0
- package/layers/metadata-comicvine/package.json +12 -0
- package/layers/metadata-comicvine/server/plugins/comicvine-register.ts +3 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/client.ts +52 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/normalise.ts +55 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/provider.ts +39 -0
- package/layers/metadata-comicvine/server/utils/metadata-comicvine/types.ts +36 -0
- package/layers/metadata-comicvine/tsconfig.json +7 -0
- package/layers/metadata-google-books/nuxt.config.ts +32 -0
- package/layers/metadata-google-books/package.json +12 -0
- package/layers/metadata-google-books/server/plugins/google-books-register.ts +3 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/client.ts +23 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/normalise.ts +39 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/provider.ts +21 -0
- package/layers/metadata-google-books/server/utils/metadata-google-books/types.ts +25 -0
- package/layers/metadata-google-books/tsconfig.json +7 -0
- package/layers/metadata-openlibrary/nuxt.config.ts +18 -0
- package/layers/metadata-openlibrary/package.json +12 -0
- package/layers/metadata-openlibrary/server/plugins/openlibrary-register.ts +3 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/client.ts +30 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/normalise.ts +81 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/provider.ts +25 -0
- package/layers/metadata-openlibrary/server/utils/metadata-openlibrary/types.ts +48 -0
- package/layers/metadata-openlibrary/tsconfig.json +7 -0
- 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 +12 -3
- package/types/auth.ts +7 -0
- package/types/index.ts +2 -0
- package/layers/content/app/composables/useCollectionItems.ts +0 -28
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ export default defineNuxtConfig({
|
|
|
29
29
|
| **motion** | Animations — GSAP, Locomotive Scroll |
|
|
30
30
|
| **shader** | 3D / WebGPU — Three.js, TresJS |
|
|
31
31
|
| **forms** | Validation — Zod-based form system |
|
|
32
|
+
| **database** | Storage — Drizzle ORM + Neon Postgres |
|
|
33
|
+
| **auth** | Authentication — nuxt-auth-utils + GitHub OAuth |
|
|
32
34
|
| **theme** | Theming — color mode, accent colors |
|
|
33
35
|
| **content** | CMS — @nuxt/content, Nuxt Studio |
|
|
34
36
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -15,6 +15,8 @@ nuxt-layers/
|
|
|
15
15
|
│ ├── motion/ # GSAP + Locomotive Scroll animations
|
|
16
16
|
│ ├── shader/ # Three.js / TresJS WebGPU shaders
|
|
17
17
|
│ ├── forms/ # Zod-based form validation
|
|
18
|
+
│ ├── database/ # Drizzle + Neon Postgres connection
|
|
19
|
+
│ ├── auth/ # nuxt-auth-utils + GitHub OAuth
|
|
18
20
|
│ ├── theme/ # Color mode, accent colors, accessibility
|
|
19
21
|
│ └── content/ # @nuxt/content integration, Nuxt Studio
|
|
20
22
|
├── packages/ # Shared utilities (placeholder)
|
|
@@ -192,5 +194,7 @@ Some layers (ui, content) support standalone mode via environment variables (`UI
|
|
|
192
194
|
| **motion** | Animation layer. GSAP with ScrollTrigger, Locomotive Scroll (Lenis) smooth scrolling, motion composables. Transpiles GSAP for SSR. |
|
|
193
195
|
| **shader** | WebGPU/WebGL layer. Three.js + TresJS integration, TSL shader support, post-processing. Includes custom shader utilities and auto-imported shader modules. |
|
|
194
196
|
| **forms** | Form validation layer. Zod schema-based validation, form composables, typed form components. |
|
|
197
|
+
| **database** | Database infrastructure. Drizzle ORM + Neon Postgres via HTTP driver. Exposes `useSql()` and `useDrizzle(schema)` server utilities. App-owned schema and migrations; env: `NUXT_DATABASE_LAYER_URL`. |
|
|
198
|
+
| **auth** | Authentication layer. nuxt-auth-utils + GitHub OAuth handler, sealed-cookie sessions. Opt-in `auth` route middleware; no user table (app's responsibility). Env: `NUXT_SESSION_PASSWORD`, `NUXT_OAUTH_GITHUB_CLIENT_ID/SECRET`. |
|
|
195
199
|
| **theme** | Theming layer. Color mode switching, accent color palette, theme plugin, CSS custom properties for dynamic theming. |
|
|
196
200
|
| **content** | Content management layer. @nuxt/content integration, better-sqlite3 for local content DB, optional Nuxt Studio support. |
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'auth',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
modules: ['nuxt-auth-utils'],
|
|
9
|
+
|
|
10
|
+
alias: {
|
|
11
|
+
'#layers/auth': import.meta.dirname,
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
compatibilityDate: '2026-06-28',
|
|
15
|
+
|
|
16
|
+
runtimeConfig: {
|
|
17
|
+
// nuxt-auth-utils reads these env vars automatically:
|
|
18
|
+
session: { password: '' }, // env: NUXT_SESSION_PASSWORD
|
|
19
|
+
oauth: {
|
|
20
|
+
github: {
|
|
21
|
+
clientId: '', // env: NUXT_OAUTH_GITHUB_CLIENT_ID
|
|
22
|
+
clientSecret: '', // env: NUXT_OAUTH_GITHUB_CLIENT_SECRET
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
typescript: {
|
|
28
|
+
typeCheck: false,
|
|
29
|
+
strict: true,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-auth",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./nuxt.config.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "nuxi dev",
|
|
8
|
+
"dev:playground": "PLAYGROUND_LAYERS=core,auth pnpm -F playground dev",
|
|
9
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"nuxt-auth-utils": "catalog:"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default defineOAuthGitHubEventHandler({
|
|
2
|
+
async onSuccess(event, { user }) {
|
|
3
|
+
await setUserSession(event, {
|
|
4
|
+
user: {
|
|
5
|
+
provider: 'github',
|
|
6
|
+
providerId: user.id,
|
|
7
|
+
username: user.login,
|
|
8
|
+
avatarUrl: user.avatar_url,
|
|
9
|
+
},
|
|
10
|
+
})
|
|
11
|
+
return sendRedirect(event, '/')
|
|
12
|
+
},
|
|
13
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
|
|
4
|
+
export default defineComponent({
|
|
5
|
+
name: 'BaselineStatus',
|
|
6
|
+
props: {
|
|
7
|
+
featureId: {
|
|
8
|
+
type: String,
|
|
9
|
+
required: true,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
setup(props) {
|
|
13
|
+
if (import.meta.server) return () => null
|
|
14
|
+
return () => h('baseline-status', { featureId: props.featureId })
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
</script>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'baseline',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
alias: {
|
|
9
|
+
'#layers/baseline': import.meta.dirname,
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
compatibilityDate: '2026-06-06',
|
|
13
|
+
|
|
14
|
+
vue: {
|
|
15
|
+
compilerOptions: {
|
|
16
|
+
isCustomElement: (tag) => tag === 'baseline-status' || tag === 'baseline-icon',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
typescript: {
|
|
21
|
+
typeCheck: false,
|
|
22
|
+
strict: true,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-baseline",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./nuxt.config.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev:playground": "PLAYGROUND_LAYERS=core,baseline pnpm -F playground dev",
|
|
8
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
9
|
+
"lint": "eslint ."
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"baseline-status": "catalog:"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'database',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
alias: {
|
|
9
|
+
'#layers/database': import.meta.dirname,
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
compatibilityDate: '2026-06-28',
|
|
13
|
+
|
|
14
|
+
runtimeConfig: {
|
|
15
|
+
databaseLayer: {
|
|
16
|
+
url: '', // env: NUXT_DATABASE_LAYER_URL (Neon connection string)
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
typescript: {
|
|
21
|
+
typeCheck: false,
|
|
22
|
+
strict: true,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
declare module '@nuxt/schema' {
|
|
27
|
+
interface RuntimeConfig {
|
|
28
|
+
databaseLayer?: { url: string }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-database",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./nuxt.config.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "nuxi dev",
|
|
8
|
+
"dev:playground": "PLAYGROUND_LAYERS=core,database pnpm -F playground dev",
|
|
9
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@neondatabase/serverless": "catalog:",
|
|
14
|
+
"drizzle-orm": "catalog:"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -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))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MetadataProviderId, MetadataRecord } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
export function useMetadataItem(provider: MetadataProviderId, providerId: string, resourceType?: string) {
|
|
4
|
+
return useAsyncData(
|
|
5
|
+
`metadata-item:${provider}:${providerId}`,
|
|
6
|
+
() =>
|
|
7
|
+
$fetch('/api/metadata/lookup', {
|
|
8
|
+
query: { provider, providerId, resourceType },
|
|
9
|
+
}) as Promise<MetadataRecord>,
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MetadataMediaType, MetadataProviderId, MetadataSearchResult } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
type UseMetadataSearchOptions = {
|
|
4
|
+
mediaType?: MetadataMediaType
|
|
5
|
+
providers?: MetadataProviderId[]
|
|
6
|
+
limit?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useMetadataSearch(query: MaybeRef<string>, options: UseMetadataSearchOptions = {}) {
|
|
10
|
+
const q = computed(() => toValue(query).trim())
|
|
11
|
+
|
|
12
|
+
return useAsyncData(
|
|
13
|
+
() => `metadata-search:${q.value}`,
|
|
14
|
+
() =>
|
|
15
|
+
$fetch('/api/metadata/search', {
|
|
16
|
+
query: {
|
|
17
|
+
q: q.value,
|
|
18
|
+
mediaType: options.mediaType,
|
|
19
|
+
providers: options.providers?.join(','),
|
|
20
|
+
limit: options.limit,
|
|
21
|
+
},
|
|
22
|
+
}) as Promise<MetadataSearchResult[]>,
|
|
23
|
+
{ watch: [q], immediate: false },
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
$meta: {
|
|
3
|
+
name: 'metadata',
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
extends: ['../core'],
|
|
7
|
+
|
|
8
|
+
alias: {
|
|
9
|
+
'#layers/metadata': import.meta.dirname,
|
|
10
|
+
'#layers/metadata/shared': `${import.meta.dirname}/shared`,
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
compatibilityDate: '2026-06-30',
|
|
14
|
+
|
|
15
|
+
nitro: {
|
|
16
|
+
storage: {
|
|
17
|
+
metadata: {
|
|
18
|
+
driver: 'memory',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
typescript: {
|
|
24
|
+
typeCheck: false,
|
|
25
|
+
strict: true,
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kmcom-layer-metadata",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./nuxt.config.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "nuxi dev",
|
|
8
|
+
"dev:playground": "PLAYGROUND_LAYERS=core,metadata pnpm -F playground dev",
|
|
9
|
+
"typecheck": "vue-tsc --noEmit -p ../../tsconfig.typecheck.json",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MetadataProviderId } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
// fallow-ignore-next-line complexity
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const query = getQuery(event)
|
|
6
|
+
|
|
7
|
+
const providerId = String(query.providerId ?? '').trim()
|
|
8
|
+
const providerName = String(query.provider ?? '').trim() as MetadataProviderId
|
|
9
|
+
const resourceType = query.resourceType ? String(query.resourceType) : undefined
|
|
10
|
+
|
|
11
|
+
if (!providerName || !providerId) {
|
|
12
|
+
throw createError({ statusCode: 400, message: 'provider and providerId are required' })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const provider = getProvider(providerName)
|
|
16
|
+
if (!provider) throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
|
|
17
|
+
|
|
18
|
+
const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
|
|
19
|
+
const cached = await getCacheRecord(cacheKey)
|
|
20
|
+
if (cached) return cached.normalised
|
|
21
|
+
|
|
22
|
+
const result = await provider.lookup({ provider: providerName, providerId, resourceType })
|
|
23
|
+
if (!result) throw createError({ statusCode: 404, message: 'Not found' })
|
|
24
|
+
|
|
25
|
+
const now = new Date().toISOString()
|
|
26
|
+
await setCacheRecord(cacheKey, {
|
|
27
|
+
id: result.id,
|
|
28
|
+
provider: providerName,
|
|
29
|
+
providerId,
|
|
30
|
+
resourceType: resourceType ?? 'item',
|
|
31
|
+
mediaType: result.mediaType,
|
|
32
|
+
normalised: result,
|
|
33
|
+
raw: result.raw ?? null,
|
|
34
|
+
createdAt: now,
|
|
35
|
+
updatedAt: now,
|
|
36
|
+
lastSyncedAt: now,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return result
|
|
40
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MetadataMediaType, MetadataProviderId } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
3
|
+
// fallow-ignore-next-line complexity
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const query = getQuery(event)
|
|
6
|
+
|
|
7
|
+
const q = String(query.q ?? '').trim()
|
|
8
|
+
if (!q) throw createError({ statusCode: 400, message: 'q is required' })
|
|
9
|
+
|
|
10
|
+
const mediaType = query.mediaType as MetadataMediaType | undefined
|
|
11
|
+
const providers = query.providers
|
|
12
|
+
? String(query.providers).split(',').filter(Boolean) as MetadataProviderId[]
|
|
13
|
+
: undefined
|
|
14
|
+
const limit = query.limit ? Number(query.limit) : 10
|
|
15
|
+
|
|
16
|
+
return searchMetadata({ query: q, mediaType, providers, limit })
|
|
17
|
+
})
|