Skip to content

Commit a485ecb

Browse files
committed
Feat: settings page
1 parent 0f19688 commit a485ecb

2 files changed

Lines changed: 172 additions & 0 deletions

File tree

assets/router/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import TemplateEditView from '../vue/views/TemplateEditView.vue'
1010
import BouncesView from '../vue/views/BouncesView.vue'
1111
import PublicPagesView from '../vue/views/PublicPagesView.vue'
1212
import PublicPageEditView from '../vue/views/PublicPageEditView.vue'
13+
import SettingsView from '../vue/views/SettingsView.vue'
1314

1415
export const router = createRouter({
1516
history: createWebHistory(),
@@ -28,6 +29,7 @@ export const router = createRouter({
2829
{ path: '/public', name: 'public-pages', component: PublicPagesView, meta: { title: 'Public Pages' } },
2930
{ path: '/public/create', name: 'public-page-create', component: PublicPageEditView, meta: { title: 'Create Public Page' } },
3031
{ path: '/public/:pageId/edit', name: 'public-page-edit', component: PublicPageEditView, meta: { title: 'Edit Public Page' } },
32+
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
3133
{ path: '/:pathMatch(.*)*', redirect: '/' },
3234
],
3335
});

assets/vue/views/SettingsView.vue

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<template>
2+
<AdminLayout>
3+
<div class="space-y-6 animate-in fade-in duration-300">
4+
<div class="bg-white rounded-xl border border-slate-200 shadow-sm p-4 sm:p-6">
5+
<div class="flex items-center justify-between">
6+
<div>
7+
<p class="text-xs uppercase tracking-wide text-slate-500">Settings</p>
8+
<h2 class="text-xl font-bold text-slate-900">Configuration</h2>
9+
</div>
10+
11+
<div class="flex items-center gap-2">
12+
<input
13+
v-model="filter"
14+
type="search"
15+
placeholder="Search keys..."
16+
class="rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-ext-wf1 focus:ring-2 focus:ring-ext-wf2"
17+
/>
18+
<button
19+
class="px-3 py-2 bg-ext-wf1 hover:bg-ext-wf3 text-white text-sm font-medium rounded-lg transition-colors"
20+
type="button"
21+
@click="loadConfigs"
22+
>
23+
Refresh
24+
</button>
25+
</div>
26+
</div>
27+
</div>
28+
29+
<section class="bg-white rounded-xl border border-slate-200 shadow-sm p-6 sm:p-8">
30+
<div v-if="isLoading" class="py-12 text-center text-slate-500">Loading settings...</div>
31+
32+
<div v-else-if="error" class="py-6 text-center text-red-600">{{ error }}</div>
33+
34+
<div v-else>
35+
<div v-if="filtered.length === 0" class="py-6 text-center text-slate-500">No configuration items found.</div>
36+
37+
<div v-else class="overflow-x-auto">
38+
<table class="w-full table-auto border-collapse">
39+
<thead>
40+
<tr class="text-left text-xs text-slate-500 border-b border-slate-100">
41+
<th class="py-2">Key</th>
42+
<th class="py-2">Value</th>
43+
<th class="py-2">Description</th>
44+
<th class="py-2">Actions</th>
45+
</tr>
46+
</thead>
47+
<tbody>
48+
<tr v-for="item in filtered" :key="item.key" class="border-b last:border-b-0">
49+
<td class="py-3 align-top text-sm text-slate-700">{{ item.key }}</td>
50+
<td class="py-3 align-top">
51+
<textarea
52+
v-model="edited[item.key]"
53+
rows="2"
54+
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-ext-wf1 focus:ring-2 focus:ring-ext-wf2"
55+
/>
56+
</td>
57+
<td class="py-3 align-top text-sm text-slate-500">{{ item.description || '-' }}</td>
58+
<td class="py-3 align-top">
59+
<div class="flex gap-2">
60+
<button
61+
class="px-3 py-1.5 bg-ext-wf1 text-white text-xs rounded-lg"
62+
:disabled="saving[item.key] === true"
63+
@click="save(item.key)"
64+
>
65+
{{ saving[item.key] ? 'Saving...' : 'Save' }}
66+
</button>
67+
<button
68+
class="px-3 py-1.5 border border-slate-300 text-xs rounded-lg"
69+
type="button"
70+
@click="reset(item.key)"
71+
>
72+
Reset
73+
</button>
74+
</div>
75+
<div v-if="errors[item.key]" class="text-xs text-red-600 mt-2">{{ errors[item.key] }}</div>
76+
<div v-if="success[item.key]" class="text-xs text-green-600 mt-2">{{ success[item.key] }}</div>
77+
</td>
78+
</tr>
79+
</tbody>
80+
</table>
81+
</div>
82+
</div>
83+
</section>
84+
</div>
85+
</AdminLayout>
86+
</template>
87+
88+
<script setup>
89+
import { ref, computed, onMounted } from 'vue'
90+
import AdminLayout from '../layouts/AdminLayout.vue'
91+
import { default as apiClient } from '../api'
92+
93+
const configs = ref([])
94+
const isLoading = ref(false)
95+
const error = ref('')
96+
const filter = ref('')
97+
const edited = ref({})
98+
const saving = ref({})
99+
const errors = ref({})
100+
const success = ref({})
101+
102+
const loadConfigs = async () => {
103+
isLoading.value = true
104+
error.value = ''
105+
try {
106+
const response = await apiClient.get('configs')
107+
const items = Array.isArray(response?.items) ? response.items : []
108+
configs.value = items
109+
// initialize edited values
110+
const map = {}
111+
items.forEach((it) => {
112+
map[it.key] = it.value ?? ''
113+
})
114+
edited.value = map
115+
} catch (err) {
116+
console.error('Failed to load configs', err)
117+
error.value = err?.message || 'Failed to load settings.'
118+
} finally {
119+
isLoading.value = false
120+
}
121+
}
122+
123+
const filtered = computed(() => {
124+
const q = filter.value.trim().toLowerCase()
125+
if (!q) return configs.value
126+
return configs.value.filter((c) => (c.key || '').toLowerCase().includes(q) || (c.description || '').toLowerCase().includes(q))
127+
})
128+
129+
const save = async (key) => {
130+
if (!Object.prototype.hasOwnProperty.call(edited.value, key)) return
131+
saving.value = { ...saving.value, [key]: true }
132+
errors.value = { ...errors.value, [key]: '' }
133+
success.value = { ...success.value, [key]: '' }
134+
135+
try {
136+
const payload = { value: edited.value[key] }
137+
const response = await apiClient.put(`configs/${encodeURIComponent(key)}`, payload)
138+
// update local cache: response may be the updated config
139+
const idx = configs.value.findIndex((c) => c.key === key)
140+
if (idx !== -1) {
141+
configs.value[idx] = response || { ...configs.value[idx], value: edited.value[key] }
142+
}
143+
success.value = { ...success.value, [key]: 'Saved' }
144+
} catch (err) {
145+
console.error('Failed to save config', key, err)
146+
const msg = err?.message || 'Save failed.'
147+
errors.value = { ...errors.value, [key]: msg }
148+
} finally {
149+
saving.value = { ...saving.value, [key]: false }
150+
setTimeout(() => {
151+
success.value = { ...success.value, [key]: '' }
152+
errors.value = { ...errors.value, [key]: '' }
153+
}, 3000)
154+
}
155+
}
156+
157+
const reset = (key) => {
158+
const original = configs.value.find((c) => c.key === key)
159+
if (original) {
160+
edited.value = { ...edited.value, [key]: original.value ?? '' }
161+
}
162+
errors.value = { ...errors.value, [key]: '' }
163+
success.value = { ...success.value, [key]: '' }
164+
}
165+
166+
onMounted(() => {
167+
loadConfigs()
168+
})
169+
</script>
170+

0 commit comments

Comments
 (0)