@plutocms/supabase 0.5.0 → 0.6.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/CHANGELOG.md +7 -0
- package/app/components/settings/SettingsGeneralPanel.vue +30 -0
- package/app/plugins/pluto-extension.ts +32 -0
- package/db/migrations/003_settings_generic_keys.sql +37 -0
- package/nuxt.config.ts +0 -6
- package/package.json +2 -3
- package/server/api/settings/index.get.ts +11 -12
- package/server/api/settings/update.post.ts +20 -19
- package/shared/types/supabase.ts +6 -3
- package/app/pages/admin/settings.vue +0 -125
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0](https://github.com/plutocms/supabase/compare/v0.5.0...v0.6.0) (2026-09-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **settings:** generic, namespaced setting keys ([#51](https://github.com/plutocms/supabase/issues/51)) ([2410552](https://github.com/plutocms/supabase/commit/2410552ce6c880ecec1fac6f0b84852ef8612bea))
|
|
9
|
+
|
|
3
10
|
## [0.5.0](https://github.com/plutocms/supabase/compare/v0.4.1...v0.5.0) (2026-09-12)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { field } = usePlutoSettings()
|
|
3
|
+
|
|
4
|
+
const websiteTitle = field('website_title')
|
|
5
|
+
const websiteDescription = field('website_description')
|
|
6
|
+
const websiteUrl = field('website_url')
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="flex flex-col gap-y-6">
|
|
11
|
+
<UFormField label="Website Title" class="w-full lg:w-1/2">
|
|
12
|
+
<UInput v-model="websiteTitle" placeholder="e.g. My Website" />
|
|
13
|
+
</UFormField>
|
|
14
|
+
|
|
15
|
+
<UFormField label="Website Description" class="w-full lg:w-1/2">
|
|
16
|
+
<UInput
|
|
17
|
+
v-model="websiteDescription"
|
|
18
|
+
placeholder="e.g. My Website Description"
|
|
19
|
+
/>
|
|
20
|
+
</UFormField>
|
|
21
|
+
|
|
22
|
+
<UFormField label="Website URL" class="w-full lg:w-1/2">
|
|
23
|
+
<UInput
|
|
24
|
+
v-model="websiteUrl"
|
|
25
|
+
type="url"
|
|
26
|
+
placeholder="e.g. https://example.com"
|
|
27
|
+
/>
|
|
28
|
+
</UFormField>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
@@ -1,8 +1,40 @@
|
|
|
1
1
|
import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
|
|
2
|
+
import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
|
|
2
3
|
|
|
3
4
|
export default defineNuxtPlugin(() => {
|
|
4
5
|
definePlutoExtension({
|
|
5
6
|
id: 'supabase',
|
|
6
7
|
navbar: { id: 'admin-navbar', component: NavbarAdminProvider },
|
|
8
|
+
settingsPanels: [
|
|
9
|
+
{
|
|
10
|
+
id: 'general',
|
|
11
|
+
order: 0,
|
|
12
|
+
title: 'General',
|
|
13
|
+
icon: 'lucide:globe',
|
|
14
|
+
component: SettingsGeneralPanel,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
settingsDriver: {
|
|
18
|
+
id: 'supabase',
|
|
19
|
+
load: async () => {
|
|
20
|
+
const response = await $fetch<{ settings: Record<string, string> }>(
|
|
21
|
+
'/api/settings'
|
|
22
|
+
)
|
|
23
|
+
return response.settings
|
|
24
|
+
},
|
|
25
|
+
save: async (patch) => {
|
|
26
|
+
await $fetch('/api/settings/update', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
body: patch,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// NavbarAdmin.vue watches this to refresh its own /api/settings
|
|
32
|
+
// read (it shows the site's own URL) — the settings page saving
|
|
33
|
+
// through the new registry must still trigger it, exactly like
|
|
34
|
+
// the old page did.
|
|
35
|
+
const hasSettingsModified = useState<number>('has_settings_modified')
|
|
36
|
+
hasSettingsModified.value = Date.now()
|
|
37
|
+
},
|
|
38
|
+
},
|
|
7
39
|
})
|
|
8
40
|
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Generic settings keys
|
|
2
|
+
--
|
|
3
|
+
-- public.settings has been a fixed 3-value enum since 001_baseline.sql —
|
|
4
|
+
-- a layer other than @plutocms/supabase could never add its own setting
|
|
5
|
+
-- without altering a shared type. This adds a free-text, namespaced key
|
|
6
|
+
-- column alongside the enum column, without removing the enum column
|
|
7
|
+
-- yet. Dropping setting_name and the TSettings enum is left for a later
|
|
8
|
+
-- migration, once no shipped code writes it, to keep this one cheap to
|
|
9
|
+
-- reason about and its effects easy to undo.
|
|
10
|
+
--
|
|
11
|
+
-- Key format: lowercase segments separated by dots, e.g. 'website_title'
|
|
12
|
+
-- or 'blog.posts_per_page'. A layer other than core must namespace its
|
|
13
|
+
-- keys with its own name, so two layers can never collide.
|
|
14
|
+
|
|
15
|
+
alter table public.settings add column if not exists setting_key text;
|
|
16
|
+
|
|
17
|
+
update public.settings set setting_key = setting_name::text where setting_key is null;
|
|
18
|
+
|
|
19
|
+
alter table public.settings alter column setting_name drop not null;
|
|
20
|
+
|
|
21
|
+
create unique index if not exists settings_setting_key_key on public.settings (setting_key);
|
|
22
|
+
|
|
23
|
+
do $$
|
|
24
|
+
begin
|
|
25
|
+
if not exists (
|
|
26
|
+
select 1 from pg_constraint
|
|
27
|
+
where conname = 'settings_setting_key_format'
|
|
28
|
+
and conrelid = 'public.settings'::regclass
|
|
29
|
+
) then
|
|
30
|
+
alter table public.settings
|
|
31
|
+
add constraint settings_setting_key_format
|
|
32
|
+
check (setting_key ~ '^[a-z0-9_]+(?:\.[a-z0-9_]+)*$') not valid;
|
|
33
|
+
end if;
|
|
34
|
+
end
|
|
35
|
+
$$;
|
|
36
|
+
|
|
37
|
+
alter table public.settings validate constraint settings_setting_key_format;
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -42,8 +42,7 @@
|
|
|
42
42
|
"@plutocms/pluto": "^0.4.0",
|
|
43
43
|
"@plutocms/utils": "^0.2.1",
|
|
44
44
|
"postgres": "^3.4.9",
|
|
45
|
-
"supabase": "^2.109.1"
|
|
46
|
-
"yup": "1.7.1"
|
|
45
|
+
"supabase": "^2.109.1"
|
|
47
46
|
},
|
|
48
47
|
"devDependencies": {
|
|
49
48
|
"@antfu/eslint-config": "^6.2.0",
|
|
@@ -3,24 +3,23 @@ import { serverSupabaseClient } from '#supabase/server'
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
const client = await serverSupabaseClient<Database>(event)
|
|
5
5
|
|
|
6
|
-
const { data } = await client
|
|
6
|
+
const { data } = await client
|
|
7
|
+
.from('settings')
|
|
8
|
+
.select('setting_key, setting_name, setting_value')
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
Database['public']['Tables']['settings']['Row']['setting_name']
|
|
10
|
-
|
|
11
|
-
// @ts-expect-error: Object.fromEntries may not infer the correct type for settings
|
|
12
|
-
const settings: Record<SettingsKey, string> = {
|
|
13
|
-
...Object.fromEntries(
|
|
14
|
-
data?.map((item) => [item.setting_name, item.setting_value]) || []
|
|
15
|
-
),
|
|
16
|
-
}
|
|
10
|
+
const settings: Record<string, string> = {}
|
|
17
11
|
|
|
18
12
|
data?.forEach((item) => {
|
|
19
|
-
|
|
13
|
+
// setting_key is null only for a row written before
|
|
14
|
+
// db/migrations/003_settings_generic_keys.sql backfilled it — fall
|
|
15
|
+
// back to the enum column so a mid-migration read never drops a row.
|
|
16
|
+
const key = item.setting_key ?? item.setting_name
|
|
17
|
+
|
|
18
|
+
if (!key || !item.setting_value) {
|
|
20
19
|
return
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
settings[
|
|
22
|
+
settings[key] = item.setting_value
|
|
24
23
|
})
|
|
25
24
|
|
|
26
25
|
return { settings }
|
|
@@ -1,36 +1,30 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
2
|
import { requireAdmin } from '../../utils/admin-guard'
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
// A key is lowercase segments separated by dots, e.g. 'website_title' or
|
|
5
|
+
// 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
|
|
6
|
+
// constraint in db/migrations/003_settings_generic_keys.sql — this turns
|
|
7
|
+
// a violation into a clean 400 instead of a raw Postgres error. A layer
|
|
8
|
+
// other than core must namespace its own keys so two layers can never
|
|
9
|
+
// collide.
|
|
10
|
+
const SETTING_KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/
|
|
11
|
+
const MAX_SETTING_VALUE_LENGTH = 10_000
|
|
12
12
|
|
|
13
13
|
export default defineEventHandler(async (event) => {
|
|
14
14
|
await requireAdmin(event)
|
|
15
15
|
|
|
16
|
-
type SettingName =
|
|
17
|
-
Database['public']['Tables']['settings']['Insert']['setting_name']
|
|
18
|
-
type FormBody = Record<SettingName, string>
|
|
19
|
-
|
|
20
16
|
const client = await serverSupabaseClient<Database>(event)
|
|
21
|
-
const body = await readBody<
|
|
17
|
+
const body = await readBody<Record<string, unknown>>(event)
|
|
22
18
|
|
|
23
19
|
if (!body || typeof body !== 'object') {
|
|
24
20
|
throw createError({ statusCode: 400, statusMessage: 'No payload sent.' })
|
|
25
21
|
}
|
|
26
22
|
|
|
27
23
|
const transformed = Object.entries(body).map(([key, value]) => {
|
|
28
|
-
if (
|
|
29
|
-
!KNOWN_SETTING_KEYS.includes(key as (typeof KNOWN_SETTING_KEYS)[number])
|
|
30
|
-
) {
|
|
24
|
+
if (!SETTING_KEY_PATTERN.test(key)) {
|
|
31
25
|
throw createError({
|
|
32
26
|
statusCode: 400,
|
|
33
|
-
statusMessage: `
|
|
27
|
+
statusMessage: `Invalid setting key: ${key}`,
|
|
34
28
|
})
|
|
35
29
|
}
|
|
36
30
|
|
|
@@ -41,15 +35,22 @@ export default defineEventHandler(async (event) => {
|
|
|
41
35
|
})
|
|
42
36
|
}
|
|
43
37
|
|
|
38
|
+
if (value.length > MAX_SETTING_VALUE_LENGTH) {
|
|
39
|
+
throw createError({
|
|
40
|
+
statusCode: 400,
|
|
41
|
+
statusMessage: `Setting "${key}" is too long.`,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
44
45
|
return {
|
|
45
|
-
|
|
46
|
+
setting_key: key,
|
|
46
47
|
setting_value: value,
|
|
47
48
|
}
|
|
48
49
|
})
|
|
49
50
|
|
|
50
51
|
const { data, error } = await client
|
|
51
52
|
.from('settings')
|
|
52
|
-
.upsert(transformed, { onConflict: '
|
|
53
|
+
.upsert(transformed, { onConflict: 'setting_key' })
|
|
53
54
|
.select()
|
|
54
55
|
|
|
55
56
|
if (error) {
|
package/shared/types/supabase.ts
CHANGED
|
@@ -256,17 +256,20 @@ export type Database = {
|
|
|
256
256
|
settings: {
|
|
257
257
|
Row: {
|
|
258
258
|
id: number
|
|
259
|
-
|
|
259
|
+
setting_key: string | null
|
|
260
|
+
setting_name: Database["public"]["Enums"]["tsettings"] | null
|
|
260
261
|
setting_value: string
|
|
261
262
|
}
|
|
262
263
|
Insert: {
|
|
263
264
|
id?: number
|
|
264
|
-
|
|
265
|
+
setting_key?: string | null
|
|
266
|
+
setting_name?: Database["public"]["Enums"]["tsettings"] | null
|
|
265
267
|
setting_value: string
|
|
266
268
|
}
|
|
267
269
|
Update: {
|
|
268
270
|
id?: number
|
|
269
|
-
|
|
271
|
+
setting_key?: string | null
|
|
272
|
+
setting_name?: Database["public"]["Enums"]["tsettings"] | null
|
|
270
273
|
setting_value?: string
|
|
271
274
|
}
|
|
272
275
|
Relationships: []
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { object, string } from 'yup'
|
|
3
|
-
|
|
4
|
-
type Form = Partial<
|
|
5
|
-
Record<
|
|
6
|
-
Database['public']['Tables']['settings']['Insert']['setting_name'],
|
|
7
|
-
string
|
|
8
|
-
>
|
|
9
|
-
>
|
|
10
|
-
|
|
11
|
-
useHead({
|
|
12
|
-
title: 'Settings',
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
const toast = useToast()
|
|
16
|
-
|
|
17
|
-
const has_settings_modified = useState('has_settings_modified', () => {
|
|
18
|
-
return Date.now()
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
const { data } = await useFetch('/api/settings')
|
|
22
|
-
|
|
23
|
-
const schema = object({
|
|
24
|
-
website_title: string().required().label('Website Title'),
|
|
25
|
-
website_description: string().required().label('Website Description'),
|
|
26
|
-
website_url: string().url().required().label('Website URL'),
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const form = ref<Form>({
|
|
30
|
-
website_title: data.value?.settings.website_title || '',
|
|
31
|
-
website_description: data.value?.settings.website_description || '',
|
|
32
|
-
website_url: data.value?.settings.website_url || '',
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
const isSubmitting = ref<boolean>(false)
|
|
36
|
-
|
|
37
|
-
async function submitForm() {
|
|
38
|
-
const payload = form.value
|
|
39
|
-
|
|
40
|
-
isSubmitting.value = true
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
await $fetch('/api/settings/update', {
|
|
44
|
-
method: 'POST',
|
|
45
|
-
body: payload,
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
toast.add({
|
|
49
|
-
title: 'Settings updated successfully',
|
|
50
|
-
color: 'success',
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
has_settings_modified.value = Date.now()
|
|
54
|
-
} catch (error) {
|
|
55
|
-
if (import.meta.dev) {
|
|
56
|
-
console.error('Error updating settings:', error)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
toast.add({
|
|
60
|
-
title: 'Failed to update settings.',
|
|
61
|
-
color: 'error',
|
|
62
|
-
})
|
|
63
|
-
} finally {
|
|
64
|
-
isSubmitting.value = false
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
</script>
|
|
68
|
-
|
|
69
|
-
<template>
|
|
70
|
-
<AdminView>
|
|
71
|
-
<h1 class="text-3xl font-bold lg:text-4xl">Settings</h1>
|
|
72
|
-
|
|
73
|
-
<UCard>
|
|
74
|
-
<UForm :schema="schema" :state="form" @submit="submitForm">
|
|
75
|
-
<div class="flex flex-col gap-y-6">
|
|
76
|
-
<UFormField
|
|
77
|
-
label="Website Title"
|
|
78
|
-
name="website_title"
|
|
79
|
-
class="w-full lg:w-1/2"
|
|
80
|
-
>
|
|
81
|
-
<UInput
|
|
82
|
-
v-model="form.website_title"
|
|
83
|
-
placeholder="e.g. My Website"
|
|
84
|
-
/>
|
|
85
|
-
</UFormField>
|
|
86
|
-
|
|
87
|
-
<UFormField
|
|
88
|
-
label="Website Description"
|
|
89
|
-
name="website_description"
|
|
90
|
-
class="w-full lg:w-1/2"
|
|
91
|
-
>
|
|
92
|
-
<UInput
|
|
93
|
-
v-model="form.website_description"
|
|
94
|
-
placeholder="e.g. My Website Description"
|
|
95
|
-
/>
|
|
96
|
-
</UFormField>
|
|
97
|
-
|
|
98
|
-
<UFormField
|
|
99
|
-
label="Website URL"
|
|
100
|
-
name="website_url"
|
|
101
|
-
class="w-full lg:w-1/2"
|
|
102
|
-
>
|
|
103
|
-
<UInput
|
|
104
|
-
v-model="form.website_url"
|
|
105
|
-
type="url"
|
|
106
|
-
placeholder="e.g. https://example.com"
|
|
107
|
-
/>
|
|
108
|
-
</UFormField>
|
|
109
|
-
|
|
110
|
-
<div class="flex">
|
|
111
|
-
<UButton
|
|
112
|
-
:loading="isSubmitting"
|
|
113
|
-
:disabled="isSubmitting"
|
|
114
|
-
type="submit"
|
|
115
|
-
icon="lucide:save"
|
|
116
|
-
class="w-full justify-center sm:w-auto"
|
|
117
|
-
>
|
|
118
|
-
Save
|
|
119
|
-
</UButton>
|
|
120
|
-
</div>
|
|
121
|
-
</div>
|
|
122
|
-
</UForm>
|
|
123
|
-
</UCard>
|
|
124
|
-
</AdminView>
|
|
125
|
-
</template>
|