Kaynağa Gözat

dev: automated commit - 2026-03-08 16:17:11

Mariano Z. 2 hafta önce
ebeveyn
işleme
4b57bdbb24

+ 1 - 0
cmd/goflare/main.go

@@ -95,6 +95,7 @@ func main() {
 
 	authed.GET("/ip-providers", ipProviderHandler.List)
 	authed.POST("/ip-providers", ipProviderHandler.Create)
+	authed.PUT("/ip-providers/reorder", ipProviderHandler.Reorder)
 	authed.GET("/ip-providers/:id", ipProviderHandler.Get)
 	authed.PUT("/ip-providers/:id", ipProviderHandler.Update)
 	authed.DELETE("/ip-providers/:id", ipProviderHandler.Delete)

+ 1 - 0
frontend/package.json

@@ -28,6 +28,7 @@
     "vue": "^3.5.26",
     "vue-router": "^4.6.4",
     "vue-sonner": "2.0.9",
+    "vuedraggable": "4.1.0",
     "zod": "3.25.76"
   },
   "devDependencies": {

+ 18 - 0
frontend/pnpm-lock.yaml

@@ -53,6 +53,9 @@ importers:
       vue-sonner:
         specifier: 2.0.9
         version: 2.0.9
+      vuedraggable:
+        specifier: 4.1.0
+        version: 4.1.0(vue@3.5.29(typescript@5.9.3))
       zod:
         specifier: 3.25.76
         version: 3.25.76
@@ -1668,6 +1671,9 @@ packages:
     resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
     engines: {node: '>=18'}
 
+  sortablejs@1.14.0:
+    resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
+
   source-map-js@1.2.1:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
@@ -1890,6 +1896,11 @@ packages:
       typescript:
         optional: true
 
+  vuedraggable@4.1.0:
+    resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
+    peerDependencies:
+      vue: ^3.0.1
+
   which@2.0.2:
     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
     engines: {node: '>= 8'}
@@ -3447,6 +3458,8 @@ snapshots:
       mrmime: 2.0.1
       totalist: 3.0.1
 
+  sortablejs@1.14.0: {}
+
   source-map-js@1.2.1: {}
 
   speakingurl@14.0.1: {}
@@ -3639,6 +3652,11 @@ snapshots:
     optionalDependencies:
       typescript: 5.9.3
 
+  vuedraggable@4.1.0(vue@3.5.29(typescript@5.9.3)):
+    dependencies:
+      sortablejs: 1.14.0
+      vue: 3.5.29(typescript@5.9.3)
+
   which@2.0.2:
     dependencies:
       isexe: 2.0.0

+ 3 - 15
frontend/src/components/AddIpProviderDialog.vue

@@ -22,6 +22,7 @@ import SpinnerLoader from '@/components/SpinnerLoader.vue'
 const props = defineProps<{
   open: boolean
   provider?: IpProvider | null
+  nextPriority?: number
 }>()
 
 const emit = defineEmits<{
@@ -37,7 +38,6 @@ const schema = toTypedSchema(
     url: z.string().url('Must be a valid URL').min(1, 'URL is required'),
     name: z.string(),
     enabled: z.number(),
-    priority: z.number(),
   }),
 )
 
@@ -47,7 +47,6 @@ const { handleSubmit, resetForm, setValues } = useForm({
     url: '',
     name: '',
     enabled: 1,
-    priority: 0,
   },
 })
 
@@ -59,10 +58,9 @@ watch(
         url: props.provider.url,
         name: props.provider.name,
         enabled: props.provider.enabled,
-        priority: props.provider.priority,
       })
     } else if (open) {
-      resetForm({ values: { url: '', name: '', enabled: 1, priority: 0 } })
+      resetForm({ values: { url: '', name: '', enabled: 1 } })
     }
   },
   { flush: 'post' },
@@ -75,7 +73,7 @@ const onSubmit = handleSubmit(async (values) => {
       url: values.url,
       name: values.name ?? '',
       enabled: Number(values.enabled ?? 1),
-      priority: Number(values.priority ?? 0),
+      priority: isEdit() ? (props.provider?.priority ?? 0) : (props.nextPriority ?? 0),
     }
     if (isEdit() && props.provider) {
       await api.put(`/api/ip-providers/${props.provider.id}`, body)
@@ -128,16 +126,6 @@ const onSubmit = handleSubmit(async (values) => {
           </FormItem>
         </FormField>
 
-        <FormField name="priority" v-slot="{ field }">
-          <FormItem>
-            <FormLabel>Priority</FormLabel>
-            <FormControl>
-              <Input v-bind="field" type="number" min="0" placeholder="0" />
-            </FormControl>
-            <FormMessage />
-          </FormItem>
-        </FormField>
-
         <DialogFooter>
           <Button type="button" variant="outline" @click="emit('update:open', false)">
             Cancel

+ 163 - 23
frontend/src/views/SettingsView.vue

@@ -20,12 +20,13 @@ import {
   DropdownMenuItem,
   DropdownMenuTrigger,
 } 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 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)
@@ -33,6 +34,7 @@ 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)
@@ -125,6 +127,22 @@ async function toggleEnabled(provider: IpProvider) {
 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>
@@ -134,7 +152,7 @@ function providerDisplayName(p: IpProvider) {
       <p class="text-muted-foreground">Configure your DDNS updater</p>
     </div>
 
-    <Card class="max-w-full sm:max-w-lg">
+    <Card>
       <CardHeader>
         <CardTitle>Cron Schedule</CardTitle>
         <CardDescription>
@@ -191,44 +209,91 @@ function providerDisplayName(p: IpProvider) {
         </div>
       </CardHeader>
       <CardContent>
-        <div class="rounded-md border">
+        <!-- Desktop table -->
+        <div class="hidden sm:block rounded-md border">
           <Table>
             <TableHeader>
               <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-12" />
               </TableRow>
             </TableHeader>
-            <TableBody>
-              <template v-if="providersLoading">
+            <template v-if="providersLoading">
+              <TableBody>
                 <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" />
                   </TableCell>
                 </TableRow>
-              </template>
-              <template v-else-if="providers.length === 0">
+              </TableBody>
+            </template>
+            <template v-else-if="providers.length === 0">
+              <TableBody>
                 <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.
                   </TableCell>
                 </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>
-                  </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
                       :model-value="provider.enabled === 1"
                       @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>
                       <DropdownMenuTrigger asChild>
                         <Button variant="ghost" size="icon" class="h-8 w-8">
@@ -246,18 +311,93 @@ function providerDisplayName(p: IpProvider) {
                         </DropdownMenuItem>
                       </DropdownMenuContent>
                     </DropdownMenu>
-                  </TableCell>
-                </TableRow>
+                  </td>
+                </tr>
               </template>
-            </TableBody>
+            </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"
     />
 

+ 34 - 0
internal/handler/ip_provider.go

@@ -119,3 +119,37 @@ func (h *IpProviderHandler) Delete(c echo.Context) error {
 
 	return c.JSON(http.StatusOK, map[string]string{"message": "IP provider deleted"})
 }
+
+type reorderRequest struct {
+	IDs []int64 `json:"ids"`
+}
+
+func (h *IpProviderHandler) Reorder(c echo.Context) error {
+	var req reorderRequest
+	if err := c.Bind(&req); err != nil {
+		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+	}
+	if len(req.IDs) == 0 {
+		return c.JSON(http.StatusBadRequest, map[string]string{"error": "ids is required"})
+	}
+
+	ctx := c.Request().Context()
+	for i, id := range req.IDs {
+		provider, err := h.q.GetIpProvider(ctx, id)
+		if err != nil {
+			return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to reorder IP providers"})
+		}
+		_, err = h.q.UpdateIpProvider(ctx, queries.UpdateIpProviderParams{
+			ID:       id,
+			Url:      provider.Url,
+			Name:     provider.Name,
+			Enabled:  provider.Enabled,
+			Priority: int64(i),
+		})
+		if err != nil {
+			return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to reorder IP providers"})
+		}
+	}
+
+	return c.JSON(http.StatusOK, map[string]string{"message": "IP providers reordered"})
+}