|
@@ -20,12 +20,13 @@ import {
|
|
|
DropdownMenuItem,
|
|
DropdownMenuItem,
|
|
|
DropdownMenuTrigger,
|
|
DropdownMenuTrigger,
|
|
|
} from '@/components/ui/dropdown-menu'
|
|
} from '@/components/ui/dropdown-menu'
|
|
|
-import { MoreHorizontal, Plus, Pencil, Trash2 } from 'lucide-vue-next'
|
|
|
|
|
|
|
+import { MoreHorizontal, Plus, Pencil, Trash2, GripVertical, Power } from 'lucide-vue-next'
|
|
|
import { api } from '@/api/client'
|
|
import { api } from '@/api/client'
|
|
|
import type { Settings, IpProvider } from '@/api/types'
|
|
import type { Settings, IpProvider } from '@/api/types'
|
|
|
import { toast } from 'vue-sonner'
|
|
import { toast } from 'vue-sonner'
|
|
|
import AddIpProviderDialog from '@/components/AddIpProviderDialog.vue'
|
|
import AddIpProviderDialog from '@/components/AddIpProviderDialog.vue'
|
|
|
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog.vue'
|
|
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog.vue'
|
|
|
|
|
+import draggable from 'vuedraggable'
|
|
|
|
|
|
|
|
const loading = ref(true)
|
|
const loading = ref(true)
|
|
|
const saving = ref(false)
|
|
const saving = ref(false)
|
|
@@ -33,6 +34,7 @@ const cronSchedule = ref('')
|
|
|
|
|
|
|
|
const providers = ref<IpProvider[]>([])
|
|
const providers = ref<IpProvider[]>([])
|
|
|
const providersLoading = ref(true)
|
|
const providersLoading = ref(true)
|
|
|
|
|
+const reordering = ref(false)
|
|
|
|
|
|
|
|
const dialogOpen = ref(false)
|
|
const dialogOpen = ref(false)
|
|
|
const editingProvider = ref<IpProvider | null>(null)
|
|
const editingProvider = ref<IpProvider | null>(null)
|
|
@@ -125,6 +127,22 @@ async function toggleEnabled(provider: IpProvider) {
|
|
|
function providerDisplayName(p: IpProvider) {
|
|
function providerDisplayName(p: IpProvider) {
|
|
|
return p.name?.trim() || p.url
|
|
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>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
@@ -134,7 +152,7 @@ function providerDisplayName(p: IpProvider) {
|
|
|
<p class="text-muted-foreground">Configure your DDNS updater</p>
|
|
<p class="text-muted-foreground">Configure your DDNS updater</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <Card class="max-w-full sm:max-w-lg">
|
|
|
|
|
|
|
+ <Card>
|
|
|
<CardHeader>
|
|
<CardHeader>
|
|
|
<CardTitle>Cron Schedule</CardTitle>
|
|
<CardTitle>Cron Schedule</CardTitle>
|
|
|
<CardDescription>
|
|
<CardDescription>
|
|
@@ -191,44 +209,91 @@ function providerDisplayName(p: IpProvider) {
|
|
|
</div>
|
|
</div>
|
|
|
</CardHeader>
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
<CardContent>
|
|
|
- <div class="rounded-md border">
|
|
|
|
|
|
|
+ <!-- Desktop table -->
|
|
|
|
|
+ <div class="hidden sm:block rounded-md border">
|
|
|
<Table>
|
|
<Table>
|
|
|
<TableHeader>
|
|
<TableHeader>
|
|
|
<TableRow>
|
|
<TableRow>
|
|
|
- <TableHead>Name / URL</TableHead>
|
|
|
|
|
|
|
+ <TableHead class="w-8" aria-label="Reorder" />
|
|
|
|
|
+ <TableHead>Name</TableHead>
|
|
|
|
|
+ <TableHead>URL</TableHead>
|
|
|
<TableHead class="w-24">Enabled</TableHead>
|
|
<TableHead class="w-24">Enabled</TableHead>
|
|
|
<TableHead class="w-12" />
|
|
<TableHead class="w-12" />
|
|
|
</TableRow>
|
|
</TableRow>
|
|
|
</TableHeader>
|
|
</TableHeader>
|
|
|
- <TableBody>
|
|
|
|
|
- <template v-if="providersLoading">
|
|
|
|
|
|
|
+ <template v-if="providersLoading">
|
|
|
|
|
+ <TableBody>
|
|
|
<TableRow v-for="i in 3" :key="i">
|
|
<TableRow v-for="i in 3" :key="i">
|
|
|
- <TableCell v-for="j in 3" :key="j">
|
|
|
|
|
|
|
+ <TableCell v-for="j in 5" :key="j">
|
|
|
<Skeleton class="h-5 w-full" />
|
|
<Skeleton class="h-5 w-full" />
|
|
|
</TableCell>
|
|
</TableCell>
|
|
|
</TableRow>
|
|
</TableRow>
|
|
|
- </template>
|
|
|
|
|
- <template v-else-if="providers.length === 0">
|
|
|
|
|
|
|
+ </TableBody>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <template v-else-if="providers.length === 0">
|
|
|
|
|
+ <TableBody>
|
|
|
<TableRow>
|
|
<TableRow>
|
|
|
- <TableCell colspan="3" class="text-center text-muted-foreground py-8">
|
|
|
|
|
|
|
+ <TableCell colspan="5" class="text-center text-muted-foreground py-8">
|
|
|
No IP providers configured. Add one or use the built-in defaults.
|
|
No IP providers configured. Add one or use the built-in defaults.
|
|
|
</TableCell>
|
|
</TableCell>
|
|
|
</TableRow>
|
|
</TableRow>
|
|
|
- </template>
|
|
|
|
|
- <template v-else>
|
|
|
|
|
- <TableRow v-for="provider in providers" :key="provider.id">
|
|
|
|
|
- <TableCell class="font-medium">
|
|
|
|
|
- <span class="block truncate max-w-md" :title="provider.url">
|
|
|
|
|
- {{ providerDisplayName(provider) }}
|
|
|
|
|
|
|
+ </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>
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell>
|
|
|
|
|
|
|
+ <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
|
|
<Switch
|
|
|
:model-value="provider.enabled === 1"
|
|
:model-value="provider.enabled === 1"
|
|
|
@update:model-value="toggleEnabled(provider)"
|
|
@update:model-value="toggleEnabled(provider)"
|
|
|
/>
|
|
/>
|
|
|
- </TableCell>
|
|
|
|
|
- <TableCell>
|
|
|
|
|
|
|
+ </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>
|
|
<DropdownMenu>
|
|
|
<DropdownMenuTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
|
<Button variant="ghost" size="icon" class="h-8 w-8">
|
|
<Button variant="ghost" size="icon" class="h-8 w-8">
|
|
@@ -246,18 +311,93 @@ function providerDisplayName(p: IpProvider) {
|
|
|
</DropdownMenuItem>
|
|
</DropdownMenuItem>
|
|
|
</DropdownMenuContent>
|
|
</DropdownMenuContent>
|
|
|
</DropdownMenu>
|
|
</DropdownMenu>
|
|
|
- </TableCell>
|
|
|
|
|
- </TableRow>
|
|
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
</template>
|
|
</template>
|
|
|
- </TableBody>
|
|
|
|
|
|
|
+ </draggable>
|
|
|
</Table>
|
|
</Table>
|
|
|
</div>
|
|
</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>
|
|
</CardContent>
|
|
|
</Card>
|
|
</Card>
|
|
|
|
|
|
|
|
<AddIpProviderDialog
|
|
<AddIpProviderDialog
|
|
|
v-model:open="dialogOpen"
|
|
v-model:open="dialogOpen"
|
|
|
:provider="editingProvider"
|
|
:provider="editingProvider"
|
|
|
|
|
+ :next-priority="providers.length"
|
|
|
@saved="loadProviders"
|
|
@saved="loadProviders"
|
|
|
/>
|
|
/>
|
|
|
|
|
|