소스 검색

dev: automated commit - 2026-03-08 14:12:03

Mariano Z. 2 주 전
부모
커밋
30ef5cf3ef

+ 0 - 9
frontend/src/components/AddRecordDialog.vue

@@ -146,15 +146,6 @@ const onSubmit = handleSubmit(async (values) => {
         </DialogDescription>
         </DialogDescription>
       </DialogHeader>
       </DialogHeader>
       <form @submit="onSubmit" class="space-y-4">
       <form @submit="onSubmit" class="space-y-4">
-        <FormField v-if="isEdit()" name="cf_record_id" v-slot="{ field }">
-          <FormItem>
-            <FormLabel class="text-muted-foreground text-xs">Cloudflare Record ID</FormLabel>
-            <FormControl>
-              <Input v-bind="field" disabled class="font-mono text-xs text-muted-foreground" />
-            </FormControl>
-          </FormItem>
-        </FormField>
-
         <div class="grid grid-cols-[1fr_auto] gap-4">
         <div class="grid grid-cols-[1fr_auto] gap-4">
           <FormField name="zone_id" v-slot="{ componentField }">
           <FormField name="zone_id" v-slot="{ componentField }">
             <FormItem>
             <FormItem>

+ 47 - 4
frontend/src/components/AppNavbar.vue

@@ -1,8 +1,9 @@
 <script setup lang="ts">
 <script setup lang="ts">
+import { ref } from 'vue'
 import { RouterLink, useRoute } from 'vue-router'
 import { RouterLink, useRoute } from 'vue-router'
 import { useAuthStore } from '@/stores/auth'
 import { useAuthStore } from '@/stores/auth'
 import { useDark, useToggle } from '@vueuse/core'
 import { useDark, useToggle } from '@vueuse/core'
-import { Flame, Sun, Moon, LogOut } from 'lucide-vue-next'
+import { Flame, Sun, Moon, LogOut, Menu, X } from 'lucide-vue-next'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
 
 
@@ -10,6 +11,7 @@ const route = useRoute()
 const auth = useAuthStore()
 const auth = useAuthStore()
 const isDark = useDark()
 const isDark = useDark()
 const toggleDark = useToggle(isDark)
 const toggleDark = useToggle(isDark)
+const mobileMenuOpen = ref(false)
 
 
 const navItems = [
 const navItems = [
   { title: 'Dashboard', url: '/' },
   { title: 'Dashboard', url: '/' },
@@ -21,17 +23,22 @@ const navItems = [
 function isActive(url: string) {
 function isActive(url: string) {
   return route.path === url
   return route.path === url
 }
 }
+
+function closeMobileMenu() {
+  mobileMenuOpen.value = false
+}
 </script>
 </script>
 
 
 <template>
 <template>
   <nav class="border-b bg-background">
   <nav class="border-b bg-background">
     <div class="mx-auto flex h-14 max-w-6xl items-center gap-6 px-4 sm:px-6">
     <div class="mx-auto flex h-14 max-w-6xl items-center gap-6 px-4 sm:px-6">
-      <RouterLink to="/" class="flex items-center gap-2 font-semibold">
+      <RouterLink to="/" class="flex items-center gap-2 font-semibold" @click="closeMobileMenu">
         <Flame class="h-5 w-5 text-orange-500" />
         <Flame class="h-5 w-5 text-orange-500" />
         <span>Goflare</span>
         <span>Goflare</span>
       </RouterLink>
       </RouterLink>
 
 
-      <div class="flex items-center gap-1">
+      <!-- Desktop nav -->
+      <div class="hidden sm:flex items-center gap-1">
         <RouterLink
         <RouterLink
           v-for="item in navItems"
           v-for="item in navItems"
           :key="item.url"
           :key="item.url"
@@ -65,7 +72,7 @@ function isActive(url: string) {
         <TooltipProvider>
         <TooltipProvider>
           <Tooltip>
           <Tooltip>
             <TooltipTrigger asChild>
             <TooltipTrigger asChild>
-              <Button variant="ghost" size="icon" @click="auth.logout()">
+              <Button variant="ghost" size="icon" class="hidden sm:inline-flex" @click="auth.logout()">
                 <LogOut class="h-4 w-4" />
                 <LogOut class="h-4 w-4" />
               </Button>
               </Button>
             </TooltipTrigger>
             </TooltipTrigger>
@@ -74,7 +81,43 @@ function isActive(url: string) {
             </TooltipContent>
             </TooltipContent>
           </Tooltip>
           </Tooltip>
         </TooltipProvider>
         </TooltipProvider>
+
+        <!-- Mobile hamburger -->
+        <Button
+          variant="ghost"
+          size="icon"
+          class="sm:hidden"
+          @click="mobileMenuOpen = !mobileMenuOpen"
+        >
+          <X v-if="mobileMenuOpen" class="h-5 w-5" />
+          <Menu v-else class="h-5 w-5" />
+        </Button>
       </div>
       </div>
     </div>
     </div>
+
+    <!-- Mobile menu -->
+    <div v-if="mobileMenuOpen" class="sm:hidden border-t bg-background px-4 py-3 space-y-1">
+      <RouterLink
+        v-for="item in navItems"
+        :key="item.url"
+        :to="item.url"
+        :class="[
+          'block rounded-md px-3 py-2 text-sm font-medium transition-colors',
+          isActive(item.url)
+            ? 'bg-accent text-accent-foreground'
+            : 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
+        ]"
+        @click="closeMobileMenu"
+      >
+        {{ item.title }}
+      </RouterLink>
+      <button
+        class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
+        @click="auth.logout()"
+      >
+        <LogOut class="h-4 w-4" />
+        Logout
+      </button>
+    </div>
   </nav>
   </nav>
 </template>
 </template>

+ 58 - 51
frontend/src/views/DashboardView.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { ref, onMounted } from 'vue'
 import { ref, onMounted } from 'vue'
+import { RouterLink } from 'vue-router'
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
 import { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
 import { Skeleton } from '@/components/ui/skeleton'
 import { Skeleton } from '@/components/ui/skeleton'
@@ -59,59 +60,65 @@ const staticRecords = () => records.value.filter((r) => r.is_static === 1).lengt
     </div>
     </div>
 
 
     <div class="grid gap-4 sm:grid-cols-3">
     <div class="grid gap-4 sm:grid-cols-3">
-      <Card>
-        <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
-          <CardTitle class="text-sm font-medium">Public IP</CardTitle>
-          <TooltipProvider>
-            <Tooltip>
-              <TooltipTrigger asChild>
-                <span class="inline-flex cursor-default">
-                  <Globe class="h-4 w-4 text-muted-foreground" />
-                </span>
-              </TooltipTrigger>
-              <TooltipContent>
-                <p>Fetched from: {{ ipSource || 'Unknown' }}</p>
-              </TooltipContent>
-            </Tooltip>
-          </TooltipProvider>
-        </CardHeader>
-        <CardContent>
-          <Skeleton v-if="loading" class="h-7 w-36" />
-          <div v-else class="text-2xl font-bold font-mono">{{ ip }}</div>
-          <CardDescription class="mt-1">Current detected IP address</CardDescription>
-          <p v-if="!loading && ipUpdatedAt" class="mt-1 text-xs text-muted-foreground">
-            Last changed: {{ formatIpUpdatedAt(ipUpdatedAt) }}
-          </p>
-        </CardContent>
-      </Card>
+      <RouterLink to="/settings">
+        <Card class="transition-colors hover:bg-accent/50 cursor-pointer">
+          <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
+            <CardTitle class="text-sm font-medium">Public IP</CardTitle>
+            <TooltipProvider>
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <span class="inline-flex cursor-default">
+                    <Globe class="h-4 w-4 text-muted-foreground" />
+                  </span>
+                </TooltipTrigger>
+                <TooltipContent>
+                  <p>Fetched from: {{ ipSource || 'Unknown' }}</p>
+                </TooltipContent>
+              </Tooltip>
+            </TooltipProvider>
+          </CardHeader>
+          <CardContent>
+            <Skeleton v-if="loading" class="h-7 w-36" />
+            <div v-else class="text-2xl font-bold font-mono">{{ ip }}</div>
+            <CardDescription class="mt-1">Current detected IP address</CardDescription>
+            <p v-if="!loading && ipUpdatedAt" class="mt-1 text-xs text-muted-foreground">
+              Last changed: {{ formatIpUpdatedAt(ipUpdatedAt) }}
+            </p>
+          </CardContent>
+        </Card>
+      </RouterLink>
 
 
-      <Card>
-        <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
-          <CardTitle class="text-sm font-medium">Zones</CardTitle>
-          <Layers class="h-4 w-4 text-muted-foreground" />
-        </CardHeader>
-        <CardContent>
-          <Skeleton v-if="loading" class="h-7 w-12" />
-          <div v-else class="text-2xl font-bold">{{ zones.length }}</div>
-          <CardDescription class="mt-1">Cloudflare zones configured</CardDescription>
-        </CardContent>
-      </Card>
+      <RouterLink to="/zones">
+        <Card class="transition-colors hover:bg-accent/50 cursor-pointer">
+          <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
+            <CardTitle class="text-sm font-medium">Zones</CardTitle>
+            <Layers class="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <Skeleton v-if="loading" class="h-7 w-12" />
+            <div v-else class="text-2xl font-bold">{{ zones.length }}</div>
+            <CardDescription class="mt-1">Cloudflare zones configured</CardDescription>
+          </CardContent>
+        </Card>
+      </RouterLink>
 
 
-      <Card>
-        <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
-          <CardTitle class="text-sm font-medium">DNS Records</CardTitle>
-          <FileText class="h-4 w-4 text-muted-foreground" />
-        </CardHeader>
-        <CardContent>
-          <Skeleton v-if="loading" class="h-7 w-24" />
-          <div v-else class="flex items-center gap-2">
-            <span class="text-2xl font-bold">{{ records.length }}</span>
-            <Badge variant="secondary">{{ dynamicRecords() }} dynamic</Badge>
-            <Badge variant="outline">{{ staticRecords() }} static</Badge>
-          </div>
-          <CardDescription class="mt-1">Total managed records</CardDescription>
-        </CardContent>
-      </Card>
+      <RouterLink to="/records">
+        <Card class="transition-colors hover:bg-accent/50 cursor-pointer">
+          <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
+            <CardTitle class="text-sm font-medium">DNS Records</CardTitle>
+            <FileText class="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <Skeleton v-if="loading" class="h-7 w-24" />
+            <div v-else class="flex items-center gap-2 flex-wrap">
+              <span class="text-2xl font-bold">{{ records.length }}</span>
+              <Badge variant="secondary">{{ dynamicRecords() }} dynamic</Badge>
+              <Badge variant="outline">{{ staticRecords() }} static</Badge>
+            </div>
+            <CardDescription class="mt-1">Total managed records</CardDescription>
+          </CardContent>
+        </Card>
+      </RouterLink>
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>

+ 127 - 8
frontend/src/views/RecordsView.vue

@@ -133,26 +133,27 @@ onMounted(loadData)
 
 
 <template>
 <template>
   <div class="space-y-6">
   <div class="space-y-6">
-    <div class="flex items-center justify-between">
+    <div class="flex items-start justify-between gap-4">
       <div>
       <div>
         <h1 class="text-2xl font-bold tracking-tight">DNS Records</h1>
         <h1 class="text-2xl font-bold tracking-tight">DNS Records</h1>
         <p class="text-muted-foreground">Manage DNS records across your zones</p>
         <p class="text-muted-foreground">Manage DNS records across your zones</p>
       </div>
       </div>
-      <div class="flex gap-2">
+      <div class="flex shrink-0 gap-2">
         <Button variant="outline" @click="manualSync" :disabled="syncing">
         <Button variant="outline" @click="manualSync" :disabled="syncing">
           <RefreshCw class="mr-2 h-4 w-4" :class="{ 'animate-spin': syncing }" />
           <RefreshCw class="mr-2 h-4 w-4" :class="{ 'animate-spin': syncing }" />
-          Sync
+          <span class="hidden sm:inline">Sync</span>
         </Button>
         </Button>
         <Button class="bg-orange-500 hover:bg-orange-500/90 text-white" @click="openAdd">
         <Button class="bg-orange-500 hover:bg-orange-500/90 text-white" @click="openAdd">
           <Plus class="mr-2 h-4 w-4" />
           <Plus class="mr-2 h-4 w-4" />
-          Add Record
+          <span class="hidden sm:inline">Add Record</span>
+          <span class="sm:hidden">Add</span>
         </Button>
         </Button>
       </div>
       </div>
     </div>
     </div>
 
 
     <div class="flex items-center gap-4">
     <div class="flex items-center gap-4">
       <Select v-model="selectedZone">
       <Select v-model="selectedZone">
-        <SelectTrigger class="w-[200px]">
+        <SelectTrigger class="w-full sm:w-[200px]">
           <SelectValue placeholder="Filter by zone" />
           <SelectValue placeholder="Filter by zone" />
         </SelectTrigger>
         </SelectTrigger>
         <SelectContent>
         <SelectContent>
@@ -165,7 +166,8 @@ onMounted(loadData)
     </div>
     </div>
 
 
     <template v-if="loading">
     <template v-if="loading">
-      <div class="rounded-md border">
+      <!-- Desktop skeleton -->
+      <div class="hidden sm:block rounded-md border">
         <Table>
         <Table>
           <TableHeader>
           <TableHeader>
             <TableRow>
             <TableRow>
@@ -186,6 +188,14 @@ onMounted(loadData)
           </TableBody>
           </TableBody>
         </Table>
         </Table>
       </div>
       </div>
+      <!-- Mobile skeleton -->
+      <div class="sm:hidden space-y-3">
+        <div v-for="i in 3" :key="i" class="rounded-lg border p-4 space-y-2">
+          <Skeleton class="h-5 w-40" />
+          <Skeleton class="h-4 w-full" />
+          <Skeleton class="h-4 w-24" />
+        </div>
+      </div>
     </template>
     </template>
 
 
     <template v-else-if="filteredRecords.length === 0">
     <template v-else-if="filteredRecords.length === 0">
@@ -203,6 +213,7 @@ onMounted(loadData)
     </template>
     </template>
 
 
     <template v-else>
     <template v-else>
+      <!-- Dynamic Records -->
       <Collapsible :default-open="true" v-slot="{ open }">
       <Collapsible :default-open="true" v-slot="{ open }">
         <div class="flex items-center justify-between">
         <div class="flex items-center justify-between">
           <div class="space-y-1">
           <div class="space-y-1">
@@ -216,7 +227,8 @@ onMounted(loadData)
           </CollapsibleTrigger>
           </CollapsibleTrigger>
         </div>
         </div>
         <CollapsibleContent>
         <CollapsibleContent>
-          <div class="rounded-md border mt-3">
+          <!-- Desktop table -->
+          <div class="hidden sm:block rounded-md border mt-3">
             <Table>
             <Table>
               <TableHeader>
               <TableHeader>
                 <TableRow>
                 <TableRow>
@@ -284,9 +296,64 @@ onMounted(loadData)
               </TableBody>
               </TableBody>
             </Table>
             </Table>
           </div>
           </div>
+          <!-- Mobile cards -->
+          <div class="sm:hidden mt-3 space-y-3">
+            <div v-if="dynamicRecords.length === 0" class="rounded-lg border p-6 text-center text-sm text-muted-foreground">
+              No dynamic records. Use the actions menu to set a record as dynamic.
+            </div>
+            <div
+              v-for="record in dynamicRecords"
+              :key="record.id"
+              class="rounded-lg border p-4"
+            >
+              <div class="flex items-start justify-between gap-2">
+                <div class="min-w-0 space-y-1">
+                  <div class="flex items-center gap-2 flex-wrap">
+                    <p class="font-semibold truncate">{{ record.name }}</p>
+                    <Badge variant="outline">{{ record.type }}</Badge>
+                    <Badge
+                      :class="record.proxied === 1
+                        ? 'bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30'
+                        : 'bg-orange-500/15 text-orange-700 dark:text-orange-400 border-orange-500/30'"
+                      variant="outline"
+                    >
+                      {{ record.proxied === 1 ? 'Proxied' : 'Not proxied' }}
+                    </Badge>
+                  </div>
+                  <p class="text-xs font-mono text-muted-foreground break-all">{{ record.content }}</p>
+                  <p class="text-xs text-muted-foreground">Zone: {{ record.zone_name }}</p>
+                  <p v-if="record.last_updated_at" class="text-xs text-muted-foreground">
+                    Updated: {{ formatDate(record.last_updated_at) }}
+                  </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="toggleStatic(record)">
+                      <ArrowLeftRight class="mr-2 h-4 w-4" />
+                      Set static
+                    </DropdownMenuItem>
+                    <DropdownMenuItem @click="openEdit(record)">
+                      <Pencil class="mr-2 h-4 w-4" />
+                      Edit
+                    </DropdownMenuItem>
+                    <DropdownMenuItem class="text-destructive" @click="openDelete(record)">
+                      <Trash2 class="mr-2 h-4 w-4" />
+                      Delete
+                    </DropdownMenuItem>
+                  </DropdownMenuContent>
+                </DropdownMenu>
+              </div>
+            </div>
+          </div>
         </CollapsibleContent>
         </CollapsibleContent>
       </Collapsible>
       </Collapsible>
 
 
+      <!-- Static Records -->
       <Collapsible :default-open="true" v-slot="{ open }">
       <Collapsible :default-open="true" v-slot="{ open }">
         <div class="flex items-center justify-between">
         <div class="flex items-center justify-between">
           <div class="space-y-1">
           <div class="space-y-1">
@@ -300,7 +367,8 @@ onMounted(loadData)
           </CollapsibleTrigger>
           </CollapsibleTrigger>
         </div>
         </div>
         <CollapsibleContent>
         <CollapsibleContent>
-          <div class="rounded-md border mt-3">
+          <!-- Desktop table -->
+          <div class="hidden sm:block rounded-md border mt-3">
             <Table>
             <Table>
               <TableHeader>
               <TableHeader>
                 <TableRow>
                 <TableRow>
@@ -364,6 +432,57 @@ onMounted(loadData)
               </TableBody>
               </TableBody>
             </Table>
             </Table>
           </div>
           </div>
+          <!-- Mobile cards -->
+          <div class="sm:hidden mt-3 space-y-3">
+            <div v-if="staticRecords.length === 0" class="rounded-lg border p-6 text-center text-sm text-muted-foreground">
+              No static records.
+            </div>
+            <div
+              v-for="record in staticRecords"
+              :key="record.id"
+              class="rounded-lg border p-4"
+            >
+              <div class="flex items-start justify-between gap-2">
+                <div class="min-w-0 space-y-1">
+                  <div class="flex items-center gap-2 flex-wrap">
+                    <p class="font-semibold truncate">{{ record.name }}</p>
+                    <Badge variant="outline">{{ record.type }}</Badge>
+                    <Badge
+                      :class="record.proxied === 1
+                        ? 'bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30'
+                        : 'bg-orange-500/15 text-orange-700 dark:text-orange-400 border-orange-500/30'"
+                      variant="outline"
+                    >
+                      {{ record.proxied === 1 ? 'Proxied' : 'Not proxied' }}
+                    </Badge>
+                  </div>
+                  <p class="text-xs font-mono text-muted-foreground break-all">{{ record.content }}</p>
+                  <p class="text-xs text-muted-foreground">Zone: {{ record.zone_name }}</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="toggleStatic(record)">
+                      <ArrowLeftRight class="mr-2 h-4 w-4" />
+                      Set dynamic
+                    </DropdownMenuItem>
+                    <DropdownMenuItem @click="openEdit(record)">
+                      <Pencil class="mr-2 h-4 w-4" />
+                      Edit
+                    </DropdownMenuItem>
+                    <DropdownMenuItem class="text-destructive" @click="openDelete(record)">
+                      <Trash2 class="mr-2 h-4 w-4" />
+                      Delete
+                    </DropdownMenuItem>
+                  </DropdownMenuContent>
+                </DropdownMenu>
+              </div>
+            </div>
+          </div>
         </CollapsibleContent>
         </CollapsibleContent>
       </Collapsible>
       </Collapsible>
     </template>
     </template>

+ 5 - 5
frontend/src/views/SettingsView.vue

@@ -134,7 +134,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-lg">
+    <Card class="max-w-full sm:max-w-lg">
       <CardHeader>
       <CardHeader>
         <CardTitle>Cron Schedule</CardTitle>
         <CardTitle>Cron Schedule</CardTitle>
         <CardDescription>
         <CardDescription>
@@ -153,7 +153,7 @@ function providerDisplayName(p: IpProvider) {
         <template v-if="loading">
         <template v-if="loading">
           <Skeleton class="h-9 w-full" />
           <Skeleton class="h-9 w-full" />
         </template>
         </template>
-        <form v-else class="flex items-end gap-3" @submit.prevent="save">
+        <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">
           <div class="flex-1 space-y-2">
             <Label for="cron-schedule">Schedule</Label>
             <Label for="cron-schedule">Schedule</Label>
             <Input
             <Input
@@ -165,7 +165,7 @@ function providerDisplayName(p: IpProvider) {
           </div>
           </div>
           <Button
           <Button
             type="submit"
             type="submit"
-            class="bg-orange-500 hover:bg-orange-500/90 text-white"
+            class="w-full sm:w-auto bg-orange-500 hover:bg-orange-500/90 text-white"
             :disabled="saving"
             :disabled="saving"
           >
           >
             {{ saving ? 'Saving...' : 'Save' }}
             {{ saving ? 'Saving...' : 'Save' }}
@@ -176,7 +176,7 @@ function providerDisplayName(p: IpProvider) {
 
 
     <Card>
     <Card>
       <CardHeader>
       <CardHeader>
-        <div class="flex items-center justify-between">
+        <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
           <div>
           <div>
             <CardTitle>IP Providers</CardTitle>
             <CardTitle>IP Providers</CardTitle>
             <CardDescription>
             <CardDescription>
@@ -184,7 +184,7 @@ function providerDisplayName(p: IpProvider) {
               or reorder to change priority.
               or reorder to change priority.
             </CardDescription>
             </CardDescription>
           </div>
           </div>
-          <Button class="bg-orange-500 hover:bg-orange-500/90 text-white" @click="openAdd">
+          <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" />
             <Plus class="mr-2 h-4 w-4" />
             Add Provider
             Add Provider
           </Button>
           </Button>

+ 51 - 1
frontend/src/views/ZonesView.vue

@@ -91,7 +91,8 @@ onMounted(loadZones)
       </Button>
       </Button>
     </div>
     </div>
 
 
-    <div class="rounded-md border">
+    <!-- Desktop table -->
+    <div class="hidden sm:block rounded-md border">
       <Table>
       <Table>
         <TableHeader>
         <TableHeader>
           <TableRow>
           <TableRow>
@@ -150,6 +151,55 @@ onMounted(loadZones)
       </Table>
       </Table>
     </div>
     </div>
 
 
+    <!-- Mobile card list -->
+    <div class="sm:hidden space-y-3">
+      <template v-if="loading">
+        <div v-for="i in 3" :key="i" class="rounded-lg border p-4 space-y-2">
+          <Skeleton class="h-5 w-32" />
+          <Skeleton class="h-4 w-full" />
+          <Skeleton class="h-4 w-24" />
+        </div>
+      </template>
+      <template v-else-if="zones.length === 0">
+        <div class="rounded-lg border p-8 text-center text-muted-foreground">
+          No zones configured yet. Add one to get started.
+        </div>
+      </template>
+      <template v-else>
+        <div
+          v-for="zone in zones"
+          :key="zone.id"
+          class="rounded-lg border p-4"
+        >
+          <div class="flex items-start justify-between gap-2">
+            <div class="min-w-0 space-y-1">
+              <p class="font-semibold truncate">{{ zone.name }}</p>
+              <p class="text-xs font-mono text-muted-foreground truncate">{{ zone.zone_id }}</p>
+              <p class="text-xs font-mono text-muted-foreground">Key: {{ maskKey(zone.api_key) }}</p>
+              <p class="text-xs text-muted-foreground">{{ formatDate(zone.created_at) }}</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="openEdit(zone)">
+                  <Pencil class="mr-2 h-4 w-4" />
+                  Edit
+                </DropdownMenuItem>
+                <DropdownMenuItem class="text-destructive" @click="openDelete(zone)">
+                  <Trash2 class="mr-2 h-4 w-4" />
+                  Delete
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
+          </div>
+        </div>
+      </template>
+    </div>
+
     <AddZoneDialog v-model:open="dialogOpen" :zone="editingZone" @saved="loadZones" />
     <AddZoneDialog v-model:open="dialogOpen" :zone="editingZone" @saved="loadZones" />
 
 
     <DeleteConfirmDialog
     <DeleteConfirmDialog