SettingsView.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. <script setup lang="ts">
  2. import { ref, onMounted } from 'vue'
  3. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
  4. import { Input } from '@/components/ui/input'
  5. import { Label } from '@/components/ui/label'
  6. import { Button } from '@/components/ui/button'
  7. import { Skeleton } from '@/components/ui/skeleton'
  8. import { Switch } from '@/components/ui/switch'
  9. import {
  10. Table,
  11. TableBody,
  12. TableCell,
  13. TableHead,
  14. TableHeader,
  15. TableRow,
  16. } from '@/components/ui/table'
  17. import {
  18. DropdownMenu,
  19. DropdownMenuContent,
  20. DropdownMenuItem,
  21. DropdownMenuTrigger,
  22. } from '@/components/ui/dropdown-menu'
  23. import { MoreHorizontal, Plus, Pencil, Trash2, GripVertical, Power } from 'lucide-vue-next'
  24. import { api } from '@/api/client'
  25. import type { Settings, IpProvider } from '@/api/types'
  26. import { toast } from 'vue-sonner'
  27. import AddIpProviderDialog from '@/components/AddIpProviderDialog.vue'
  28. import DeleteConfirmDialog from '@/components/DeleteConfirmDialog.vue'
  29. import draggable from 'vuedraggable'
  30. const loading = ref(true)
  31. const saving = ref(false)
  32. const cronSchedule = ref('')
  33. const providers = ref<IpProvider[]>([])
  34. const providersLoading = ref(true)
  35. const reordering = ref(false)
  36. const dialogOpen = ref(false)
  37. const editingProvider = ref<IpProvider | null>(null)
  38. const deleteOpen = ref(false)
  39. const deletingProvider = ref<IpProvider | null>(null)
  40. onMounted(async () => {
  41. try {
  42. const [settings, providersData] = await Promise.all([
  43. api.get<Settings>('/api/settings'),
  44. api.get<IpProvider[]>('/api/ip-providers'),
  45. ])
  46. cronSchedule.value = settings.cron_schedule
  47. providers.value = providersData ?? []
  48. } catch (err) {
  49. toast.error(err instanceof Error ? err.message : 'Failed to load settings')
  50. } finally {
  51. loading.value = false
  52. providersLoading.value = false
  53. }
  54. })
  55. async function loadProviders() {
  56. try {
  57. providers.value = await api.get<IpProvider[]>('/api/ip-providers')
  58. } catch (err) {
  59. toast.error(err instanceof Error ? err.message : 'Failed to load IP providers')
  60. }
  61. }
  62. async function save() {
  63. saving.value = true
  64. try {
  65. const settings = await api.put<Settings>('/api/settings', {
  66. cron_schedule: cronSchedule.value,
  67. })
  68. cronSchedule.value = settings.cron_schedule
  69. toast.success('Settings saved')
  70. } catch (err) {
  71. toast.error(err instanceof Error ? err.message : 'Failed to save settings')
  72. } finally {
  73. saving.value = false
  74. }
  75. }
  76. function openAdd() {
  77. editingProvider.value = null
  78. dialogOpen.value = true
  79. }
  80. function openEdit(provider: IpProvider) {
  81. editingProvider.value = provider
  82. dialogOpen.value = true
  83. }
  84. function openDelete(provider: IpProvider) {
  85. deletingProvider.value = provider
  86. deleteOpen.value = true
  87. }
  88. async function confirmDelete() {
  89. if (!deletingProvider.value) return
  90. try {
  91. await api.delete(`/api/ip-providers/${deletingProvider.value.id}`)
  92. toast.success('IP provider deleted')
  93. deleteOpen.value = false
  94. await loadProviders()
  95. } catch (err) {
  96. toast.error(err instanceof Error ? err.message : 'Failed to delete IP provider')
  97. }
  98. }
  99. async function toggleEnabled(provider: IpProvider) {
  100. try {
  101. const newEnabled = provider.enabled === 1 ? 0 : 1
  102. await api.put(`/api/ip-providers/${provider.id}`, {
  103. url: provider.url,
  104. name: provider.name,
  105. enabled: newEnabled,
  106. priority: provider.priority,
  107. })
  108. provider.enabled = newEnabled
  109. toast.success(newEnabled === 1 ? 'Provider enabled' : 'Provider disabled')
  110. } catch (err) {
  111. toast.error(err instanceof Error ? err.message : 'Failed to update provider')
  112. }
  113. }
  114. function providerDisplayName(p: IpProvider) {
  115. return p.name?.trim() || p.url
  116. }
  117. async function onReorder() {
  118. reordering.value = true
  119. try {
  120. const ids = providers.value.map((p) => p.id)
  121. await api.put('/api/ip-providers/reorder', { ids })
  122. providers.value.forEach((p, i) => {
  123. p.priority = i
  124. })
  125. } catch (err) {
  126. toast.error(err instanceof Error ? err.message : 'Failed to reorder IP providers')
  127. await loadProviders()
  128. } finally {
  129. reordering.value = false
  130. }
  131. }
  132. </script>
  133. <template>
  134. <div class="space-y-6">
  135. <div>
  136. <h1 class="text-2xl font-bold tracking-tight">Settings</h1>
  137. <p class="text-muted-foreground">Configure your DDNS updater</p>
  138. </div>
  139. <Card>
  140. <CardHeader>
  141. <CardTitle>Cron Schedule</CardTitle>
  142. <CardDescription>
  143. How often the DDNS updater checks for IP changes. Uses
  144. <a
  145. href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-Predefined_schedules"
  146. target="_blank"
  147. class="underline text-orange-500"
  148. >robfig/cron</a>
  149. syntax, e.g. <code class="text-xs bg-muted px-1 py-0.5 rounded">@every 1m</code>,
  150. <code class="text-xs bg-muted px-1 py-0.5 rounded">@every 5m</code>,
  151. <code class="text-xs bg-muted px-1 py-0.5 rounded">*/5 * * * *</code>.
  152. </CardDescription>
  153. </CardHeader>
  154. <CardContent>
  155. <template v-if="loading">
  156. <Skeleton class="h-9 w-full" />
  157. </template>
  158. <form v-else class="flex flex-col sm:flex-row sm:items-end gap-3" @submit.prevent="save">
  159. <div class="flex-1 space-y-2">
  160. <Label for="cron-schedule">Schedule</Label>
  161. <Input
  162. id="cron-schedule"
  163. v-model="cronSchedule"
  164. placeholder="@every 1m"
  165. required
  166. />
  167. </div>
  168. <Button
  169. type="submit"
  170. class="w-full sm:w-auto bg-orange-500 hover:bg-orange-500/90 text-white"
  171. :disabled="saving"
  172. >
  173. {{ saving ? 'Saving...' : 'Save' }}
  174. </Button>
  175. </form>
  176. </CardContent>
  177. </Card>
  178. <Card>
  179. <CardHeader>
  180. <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
  181. <div>
  182. <CardTitle>IP Providers</CardTitle>
  183. <CardDescription>
  184. Services used to detect your public IP. The first responding provider is used; disable
  185. or reorder to change priority.
  186. </CardDescription>
  187. </div>
  188. <Button class="shrink-0 self-start sm:self-auto bg-orange-500 hover:bg-orange-500/90 text-white" @click="openAdd">
  189. <Plus class="mr-2 h-4 w-4" />
  190. Add Provider
  191. </Button>
  192. </div>
  193. </CardHeader>
  194. <CardContent>
  195. <!-- Desktop table -->
  196. <div class="hidden sm:block rounded-md border">
  197. <Table>
  198. <TableHeader>
  199. <TableRow>
  200. <TableHead class="w-8" aria-label="Reorder" />
  201. <TableHead>Name</TableHead>
  202. <TableHead>URL</TableHead>
  203. <TableHead class="w-24">Enabled</TableHead>
  204. <TableHead class="w-12" />
  205. </TableRow>
  206. </TableHeader>
  207. <template v-if="providersLoading">
  208. <TableBody>
  209. <TableRow v-for="i in 3" :key="i">
  210. <TableCell v-for="j in 5" :key="j">
  211. <Skeleton class="h-5 w-full" />
  212. </TableCell>
  213. </TableRow>
  214. </TableBody>
  215. </template>
  216. <template v-else-if="providers.length === 0">
  217. <TableBody>
  218. <TableRow>
  219. <TableCell colspan="5" class="text-center text-muted-foreground py-8">
  220. No IP providers configured. Add one or use the built-in defaults.
  221. </TableCell>
  222. </TableRow>
  223. </TableBody>
  224. </template>
  225. <draggable
  226. v-else
  227. v-model="providers"
  228. tag="tbody"
  229. item-key="id"
  230. handle=".drag-handle"
  231. :disabled="reordering"
  232. @end="onReorder"
  233. >
  234. <template #item="{ element: provider }">
  235. <tr
  236. :key="provider.id"
  237. data-slot="table-row"
  238. class="hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors"
  239. >
  240. <td
  241. data-slot="table-cell"
  242. class="w-8 px-2 p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
  243. >
  244. <div class="flex items-center justify-center h-full">
  245. <GripVertical
  246. class="drag-handle h-4 w-4 text-muted-foreground/70 hover:text-foreground cursor-grab active:cursor-grabbing transition-colors rounded p-1 hover:bg-accent/50"
  247. />
  248. </div>
  249. </td>
  250. <td
  251. data-slot="table-cell"
  252. class="p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
  253. >
  254. <span v-if="provider.name?.trim()" class="font-medium" :title="provider.name">
  255. {{ provider.name.trim() }}
  256. </span>
  257. <span v-else class="text-muted-foreground">—</span>
  258. </td>
  259. <td
  260. data-slot="table-cell"
  261. class="p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
  262. >
  263. <span class="text-sm text-muted-foreground truncate font-mono block max-w-md" :title="provider.url">
  264. {{ provider.url }}
  265. </span>
  266. </td>
  267. <td
  268. data-slot="table-cell"
  269. class="w-24 p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
  270. >
  271. <Switch
  272. :model-value="provider.enabled === 1"
  273. @update:model-value="toggleEnabled(provider)"
  274. />
  275. </td>
  276. <td
  277. data-slot="table-cell"
  278. class="w-12 p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
  279. >
  280. <DropdownMenu>
  281. <DropdownMenuTrigger asChild>
  282. <Button variant="ghost" size="icon" class="h-8 w-8">
  283. <MoreHorizontal class="h-4 w-4" />
  284. </Button>
  285. </DropdownMenuTrigger>
  286. <DropdownMenuContent align="end">
  287. <DropdownMenuItem @click="openEdit(provider)">
  288. <Pencil class="mr-2 h-4 w-4" />
  289. Edit
  290. </DropdownMenuItem>
  291. <DropdownMenuItem class="text-destructive" @click="openDelete(provider)">
  292. <Trash2 class="mr-2 h-4 w-4" />
  293. Delete
  294. </DropdownMenuItem>
  295. </DropdownMenuContent>
  296. </DropdownMenu>
  297. </td>
  298. </tr>
  299. </template>
  300. </draggable>
  301. </Table>
  302. </div>
  303. <!-- Mobile cards -->
  304. <div class="sm:hidden space-y-3">
  305. <template v-if="providersLoading">
  306. <div v-for="i in 3" :key="i" class="rounded-lg border p-4">
  307. <Skeleton class="h-20 w-full" />
  308. </div>
  309. </template>
  310. <template v-else-if="providers.length === 0">
  311. <div class="rounded-lg border p-6 text-center text-sm text-muted-foreground">
  312. No IP providers configured. Add one or use the built-in defaults.
  313. </div>
  314. </template>
  315. <draggable
  316. v-else
  317. v-model="providers"
  318. item-key="id"
  319. handle=".drag-handle"
  320. :disabled="reordering"
  321. @end="onReorder"
  322. class="space-y-3"
  323. >
  324. <template #item="{ element: provider }">
  325. <div
  326. :key="provider.id"
  327. class="drag-handle rounded-lg border p-4 relative cursor-grab active:cursor-grabbing"
  328. >
  329. <div
  330. :class="[
  331. 'absolute top-2 left-2 w-2 h-2 rounded-full',
  332. provider.enabled === 1 ? 'bg-green-500' : 'bg-muted-foreground'
  333. ]"
  334. :title="provider.enabled === 1 ? 'Enabled' : 'Disabled'"
  335. />
  336. <div class="flex items-start gap-3">
  337. <div class="min-w-0 flex-1 space-y-1">
  338. <p v-if="provider.name?.trim()" class="font-semibold truncate">
  339. {{ provider.name.trim() }}
  340. </p>
  341. <p v-else class="font-semibold text-muted-foreground">—</p>
  342. <p class="text-xs font-mono text-muted-foreground truncate" :title="provider.url">
  343. {{ provider.url }}
  344. </p>
  345. </div>
  346. <DropdownMenu>
  347. <DropdownMenuTrigger asChild>
  348. <Button variant="ghost" size="icon" class="h-8 w-8 shrink-0">
  349. <MoreHorizontal class="h-4 w-4" />
  350. </Button>
  351. </DropdownMenuTrigger>
  352. <DropdownMenuContent align="end">
  353. <DropdownMenuItem @click="toggleEnabled(provider)">
  354. <Power
  355. :class="[
  356. 'mr-2 h-4 w-4',
  357. provider.enabled === 1 ? 'text-destructive' : 'text-green-500'
  358. ]"
  359. />
  360. {{ provider.enabled === 1 ? 'Disable' : 'Enable' }}
  361. </DropdownMenuItem>
  362. <DropdownMenuItem @click="openEdit(provider)">
  363. <Pencil class="mr-2 h-4 w-4" />
  364. Edit
  365. </DropdownMenuItem>
  366. <DropdownMenuItem class="text-destructive" @click="openDelete(provider)">
  367. <Trash2 class="mr-2 h-4 w-4" />
  368. Delete
  369. </DropdownMenuItem>
  370. </DropdownMenuContent>
  371. </DropdownMenu>
  372. </div>
  373. </div>
  374. </template>
  375. </draggable>
  376. </div>
  377. </CardContent>
  378. </Card>
  379. <AddIpProviderDialog
  380. v-model:open="dialogOpen"
  381. :provider="editingProvider"
  382. :next-priority="providers.length"
  383. @saved="loadProviders"
  384. />
  385. <DeleteConfirmDialog
  386. v-model:open="deleteOpen"
  387. title="Delete IP provider?"
  388. :description="
  389. deletingProvider
  390. ? `Remove '${providerDisplayName(deletingProvider)}' from the list?`
  391. : 'This action cannot be undone.'
  392. "
  393. @confirm="confirmDelete"
  394. />
  395. </div>
  396. </template>