| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- <script setup lang="ts">
- import { ref, onMounted } from 'vue'
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
- import { Input } from '@/components/ui/input'
- import { Label } from '@/components/ui/label'
- import { Button } from '@/components/ui/button'
- import { Skeleton } from '@/components/ui/skeleton'
- import { Switch } from '@/components/ui/switch'
- import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
- } from '@/components/ui/table'
- import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- } from '@/components/ui/dropdown-menu'
- import { MoreHorizontal, Plus, Pencil, Trash2, GripVertical, Power } from 'lucide-vue-next'
- import { api } from '@/api/client'
- import type { Settings, IpProvider } from '@/api/types'
- import { toast } from 'vue-sonner'
- import AddIpProviderDialog from '@/components/AddIpProviderDialog.vue'
- import DeleteConfirmDialog from '@/components/DeleteConfirmDialog.vue'
- import draggable from 'vuedraggable'
- const loading = ref(true)
- const saving = ref(false)
- const cronSchedule = ref('')
- const providers = ref<IpProvider[]>([])
- const providersLoading = ref(true)
- const reordering = ref(false)
- const dialogOpen = ref(false)
- const editingProvider = ref<IpProvider | null>(null)
- const deleteOpen = ref(false)
- const deletingProvider = ref<IpProvider | null>(null)
- onMounted(async () => {
- try {
- const [settings, providersData] = await Promise.all([
- api.get<Settings>('/api/settings'),
- api.get<IpProvider[]>('/api/ip-providers'),
- ])
- cronSchedule.value = settings.cron_schedule
- providers.value = providersData ?? []
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Failed to load settings')
- } finally {
- loading.value = false
- providersLoading.value = false
- }
- })
- async function loadProviders() {
- try {
- providers.value = await api.get<IpProvider[]>('/api/ip-providers')
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Failed to load IP providers')
- }
- }
- async function save() {
- saving.value = true
- try {
- const settings = await api.put<Settings>('/api/settings', {
- cron_schedule: cronSchedule.value,
- })
- cronSchedule.value = settings.cron_schedule
- toast.success('Settings saved')
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Failed to save settings')
- } finally {
- saving.value = false
- }
- }
- function openAdd() {
- editingProvider.value = null
- dialogOpen.value = true
- }
- function openEdit(provider: IpProvider) {
- editingProvider.value = provider
- dialogOpen.value = true
- }
- function openDelete(provider: IpProvider) {
- deletingProvider.value = provider
- deleteOpen.value = true
- }
- async function confirmDelete() {
- if (!deletingProvider.value) return
- try {
- await api.delete(`/api/ip-providers/${deletingProvider.value.id}`)
- toast.success('IP provider deleted')
- deleteOpen.value = false
- await loadProviders()
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Failed to delete IP provider')
- }
- }
- async function toggleEnabled(provider: IpProvider) {
- try {
- const newEnabled = provider.enabled === 1 ? 0 : 1
- await api.put(`/api/ip-providers/${provider.id}`, {
- url: provider.url,
- name: provider.name,
- enabled: newEnabled,
- priority: provider.priority,
- })
- provider.enabled = newEnabled
- toast.success(newEnabled === 1 ? 'Provider enabled' : 'Provider disabled')
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Failed to update provider')
- }
- }
- function providerDisplayName(p: IpProvider) {
- return p.name?.trim() || p.url
- }
- async function onReorder() {
- reordering.value = true
- try {
- const ids = providers.value.map((p) => p.id)
- await api.put('/api/ip-providers/reorder', { ids })
- providers.value.forEach((p, i) => {
- p.priority = i
- })
- } catch (err) {
- toast.error(err instanceof Error ? err.message : 'Failed to reorder IP providers')
- await loadProviders()
- } finally {
- reordering.value = false
- }
- }
- </script>
- <template>
- <div class="space-y-6">
- <div>
- <h1 class="text-2xl font-bold tracking-tight">Settings</h1>
- <p class="text-muted-foreground">Configure your DDNS updater</p>
- </div>
- <Card>
- <CardHeader>
- <CardTitle>Cron Schedule</CardTitle>
- <CardDescription>
- How often the DDNS updater checks for IP changes. Uses
- <a
- href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-Predefined_schedules"
- target="_blank"
- class="underline text-orange-500"
- >robfig/cron</a>
- syntax, e.g. <code class="text-xs bg-muted px-1 py-0.5 rounded">@every 1m</code>,
- <code class="text-xs bg-muted px-1 py-0.5 rounded">@every 5m</code>,
- <code class="text-xs bg-muted px-1 py-0.5 rounded">*/5 * * * *</code>.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <template v-if="loading">
- <Skeleton class="h-9 w-full" />
- </template>
- <form v-else class="flex flex-col sm:flex-row sm:items-end gap-3" @submit.prevent="save">
- <div class="flex-1 space-y-2">
- <Label for="cron-schedule">Schedule</Label>
- <Input
- id="cron-schedule"
- v-model="cronSchedule"
- placeholder="@every 1m"
- required
- />
- </div>
- <Button
- type="submit"
- class="w-full sm:w-auto bg-orange-500 hover:bg-orange-500/90 text-white"
- :disabled="saving"
- >
- {{ saving ? 'Saving...' : 'Save' }}
- </Button>
- </form>
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
- <div>
- <CardTitle>IP Providers</CardTitle>
- <CardDescription>
- Services used to detect your public IP. The first responding provider is used; disable
- or reorder to change priority.
- </CardDescription>
- </div>
- <Button class="shrink-0 self-start sm:self-auto bg-orange-500 hover:bg-orange-500/90 text-white" @click="openAdd">
- <Plus class="mr-2 h-4 w-4" />
- Add Provider
- </Button>
- </div>
- </CardHeader>
- <CardContent>
- <!-- Desktop table -->
- <div class="hidden sm:block rounded-md border">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead class="w-8" aria-label="Reorder" />
- <TableHead>Name</TableHead>
- <TableHead>URL</TableHead>
- <TableHead class="w-24">Enabled</TableHead>
- <TableHead class="w-12" />
- </TableRow>
- </TableHeader>
- <template v-if="providersLoading">
- <TableBody>
- <TableRow v-for="i in 3" :key="i">
- <TableCell v-for="j in 5" :key="j">
- <Skeleton class="h-5 w-full" />
- </TableCell>
- </TableRow>
- </TableBody>
- </template>
- <template v-else-if="providers.length === 0">
- <TableBody>
- <TableRow>
- <TableCell colspan="5" class="text-center text-muted-foreground py-8">
- No IP providers configured. Add one or use the built-in defaults.
- </TableCell>
- </TableRow>
- </TableBody>
- </template>
- <draggable
- v-else
- v-model="providers"
- tag="tbody"
- item-key="id"
- handle=".drag-handle"
- :disabled="reordering"
- @end="onReorder"
- >
- <template #item="{ element: provider }">
- <tr
- :key="provider.id"
- data-slot="table-row"
- class="hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors"
- >
- <td
- data-slot="table-cell"
- class="w-8 px-2 p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
- >
- <div class="flex items-center justify-center h-full">
- <GripVertical
- 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"
- />
- </div>
- </td>
- <td
- data-slot="table-cell"
- class="p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
- >
- <span v-if="provider.name?.trim()" class="font-medium" :title="provider.name">
- {{ provider.name.trim() }}
- </span>
- <span v-else class="text-muted-foreground">—</span>
- </td>
- <td
- data-slot="table-cell"
- class="p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
- >
- <span class="text-sm text-muted-foreground truncate font-mono block max-w-md" :title="provider.url">
- {{ provider.url }}
- </span>
- </td>
- <td
- data-slot="table-cell"
- class="w-24 p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
- >
- <Switch
- :model-value="provider.enabled === 1"
- @update:model-value="toggleEnabled(provider)"
- />
- </td>
- <td
- data-slot="table-cell"
- class="w-12 p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"
- >
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon" class="h-8 w-8">
- <MoreHorizontal class="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem @click="openEdit(provider)">
- <Pencil class="mr-2 h-4 w-4" />
- Edit
- </DropdownMenuItem>
- <DropdownMenuItem class="text-destructive" @click="openDelete(provider)">
- <Trash2 class="mr-2 h-4 w-4" />
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </td>
- </tr>
- </template>
- </draggable>
- </Table>
- </div>
- <!-- Mobile cards -->
- <div class="sm:hidden space-y-3">
- <template v-if="providersLoading">
- <div v-for="i in 3" :key="i" class="rounded-lg border p-4">
- <Skeleton class="h-20 w-full" />
- </div>
- </template>
- <template v-else-if="providers.length === 0">
- <div class="rounded-lg border p-6 text-center text-sm text-muted-foreground">
- No IP providers configured. Add one or use the built-in defaults.
- </div>
- </template>
- <draggable
- v-else
- v-model="providers"
- item-key="id"
- handle=".drag-handle"
- :disabled="reordering"
- @end="onReorder"
- class="space-y-3"
- >
- <template #item="{ element: provider }">
- <div
- :key="provider.id"
- class="drag-handle rounded-lg border p-4 relative cursor-grab active:cursor-grabbing"
- >
- <div
- :class="[
- 'absolute top-2 left-2 w-2 h-2 rounded-full',
- provider.enabled === 1 ? 'bg-green-500' : 'bg-muted-foreground'
- ]"
- :title="provider.enabled === 1 ? 'Enabled' : 'Disabled'"
- />
- <div class="flex items-start gap-3">
- <div class="min-w-0 flex-1 space-y-1">
- <p v-if="provider.name?.trim()" class="font-semibold truncate">
- {{ provider.name.trim() }}
- </p>
- <p v-else class="font-semibold text-muted-foreground">—</p>
- <p class="text-xs font-mono text-muted-foreground truncate" :title="provider.url">
- {{ provider.url }}
- </p>
- </div>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon" class="h-8 w-8 shrink-0">
- <MoreHorizontal class="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem @click="toggleEnabled(provider)">
- <Power
- :class="[
- 'mr-2 h-4 w-4',
- provider.enabled === 1 ? 'text-destructive' : 'text-green-500'
- ]"
- />
- {{ provider.enabled === 1 ? 'Disable' : 'Enable' }}
- </DropdownMenuItem>
- <DropdownMenuItem @click="openEdit(provider)">
- <Pencil class="mr-2 h-4 w-4" />
- Edit
- </DropdownMenuItem>
- <DropdownMenuItem class="text-destructive" @click="openDelete(provider)">
- <Trash2 class="mr-2 h-4 w-4" />
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
- </template>
- </draggable>
- </div>
- </CardContent>
- </Card>
- <AddIpProviderDialog
- v-model:open="dialogOpen"
- :provider="editingProvider"
- :next-priority="providers.length"
- @saved="loadProviders"
- />
- <DeleteConfirmDialog
- v-model:open="deleteOpen"
- title="Delete IP provider?"
- :description="
- deletingProvider
- ? `Remove '${providerDisplayName(deletingProvider)}' from the list?`
- : 'This action cannot be undone.'
- "
- @confirm="confirmDelete"
- />
- </div>
- </template>
|