Page MenuHomeDevCentral

D3971.id10317.diff
No OneTemporary

D3971.id10317.diff

diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg
--- a/frontend/src/assets/logo.svg
+++ b/frontend/src/assets/logo.svg
@@ -1 +1,234 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
+<template>
+ <svg
+ :width="size * 1.25"
+ :height="size"
+ viewBox="0 0 500 400"
+ xmlns="http://www.w3.org/2000/svg"
+ :style="{ cursor: interactive ? 'pointer' : 'default', display: 'block' }"
+ @mouseenter="interactive && (hovered = true)"
+ @mouseleave="interactive && (hovered = false)"
+ >
+ <defs>
+ <linearGradient :id="uid('ck')" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
+ <stop offset="20%" stop-color="#ffffff" :stop-opacity="ci * 0.7"/>
+ <stop offset="50%" stop-color="#ffffff" :stop-opacity="ci"/>
+ <stop offset="80%" stop-color="#ffffff" :stop-opacity="ci * 0.7"/>
+ <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient :id="uid('ca')" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
+ <stop offset="30%" stop-color="#ffffff" :stop-opacity="ci * 0.12"/>
+ <stop offset="50%" stop-color="#ffffff" :stop-opacity="ci * 0.18"/>
+ <stop offset="70%" stop-color="#ffffff" :stop-opacity="ci * 0.12"/>
+ <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient :id="uid('sc')" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
+ <stop offset="50%" stop-color="#ffffff" stop-opacity="0.6"/>
+ <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
+ </linearGradient>
+ <linearGradient :id="uid('hg')" x1="0" y1="0" x2="1" y2="1">
+ <stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"/>
+ <stop offset="100%" stop-color="#ffffff" stop-opacity="0.5"/>
+ </linearGradient>
+
+ <clipPath :id="uid('cg')">
+ <rect x="-200" y="-10" :width="250 - splitX + 200" height="420"/>
+ </clipPath>
+ <clipPath :id="uid('cd')">
+ <rect :x="250 + splitX" y="-10" :width="450 + splitX" height="420"/>
+ </clipPath>
+
+ <filter :id="uid('gl')" x="-30%" y="-30%" width="160%" height="160%">
+ <feGaussianBlur stdDeviation="3" result="b"/>
+ <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
+ </filter>
+ <filter :id="uid('cb')" x="-300%" y="-5%" width="700%" height="110%">
+ <feGaussianBlur stdDeviation="8" result="b1"/>
+ <feGaussianBlur in="SourceGraphic" stdDeviation="2.5" result="b2"/>
+ <feMerge>
+ <feMergeNode in="b1"/>
+ <feMergeNode in="b2"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <filter :id="uid('hl')" x="-80%" y="-80%" width="260%" height="260%">
+ <feGaussianBlur stdDeviation="18"/>
+ </filter>
+ <filter :id="uid('pg')" x="-200%" y="-200%" width="500%" height="500%">
+ <feGaussianBlur stdDeviation="1.5" result="b"/>
+ <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
+ </filter>
+ </defs>
+
+ <!-- ══ GEAR (LEFT HALF) ══ -->
+ <g :transform="`translate(${-splitX}, 0)`" :clip-path="`url(#${uid('cg')})`" :filter="`url(#${uid('gl')})`">
+ <circle cx="250" cy="200" r="88" fill="white" fill-opacity="0.05"/>
+ <g :transform="`rotate(${gearAngle}, 250, 200)`">
+ <path
+ fill="white" fill-opacity="0.08"
+ stroke="white" stroke-width="1.8" stroke-linejoin="round" stroke-opacity="0.9"
+ d="M 262.86 112.94 L 269.64 86.69 L 289.65 92.05 L 282.39 118.18 A 88 88 0 0 1 304.66 131.03 L 323.66 111.69 L 338.31 126.34 L 318.97 145.34 A 88 88 0 0 1 331.82 167.61 L 357.95 160.35 L 363.31 180.36 L 337.06 187.14 A 88 88 0 0 1 337.06 212.86 L 363.31 219.64 L 357.95 239.65 L 331.82 232.39 A 88 88 0 0 1 318.97 254.66 L 338.31 273.66 L 323.66 288.31 L 304.66 268.97 A 88 88 0 0 1 282.39 281.82 L 289.65 307.95 L 269.64 313.31 L 262.86 287.06 A 88 88 0 0 1 237.14 287.06 L 230.36 313.31 L 210.35 307.95 L 217.61 281.82 A 88 88 0 0 1 195.34 268.97 L 176.34 288.31 L 161.69 273.66 L 181.03 254.66 A 88 88 0 0 1 168.18 232.39 L 142.05 239.65 L 136.69 219.64 L 162.94 212.86 A 88 88 0 0 1 162.94 187.14 L 136.69 180.36 L 142.05 160.35 L 168.18 167.61 A 88 88 0 0 1 181.03 145.34 L 161.69 126.34 L 176.34 111.69 L 195.34 131.03 A 88 88 0 0 1 217.61 118.18 L 210.35 92.05 L 230.36 86.69 L 237.14 112.94 A 88 88 0 0 1 262.86 112.94 Z"
+ />
+ </g>
+ <circle cx="250" cy="200" r="66" fill="none" stroke="white" stroke-width="1.3" opacity="0.45"/>
+ <g :transform="`rotate(${-gearAngle}, 250, 200)`">
+ <line x1="250" y1="140" x2="250" y2="194" stroke="white" stroke-width="1.8" opacity="0.5"/>
+ <line x1="194" y1="166" x2="244" y2="196" stroke="white" stroke-width="1.8" opacity="0.5"/>
+ <line x1="194" y1="234" x2="244" y2="204" stroke="white" stroke-width="1.8" opacity="0.5"/>
+ <line x1="250" y1="260" x2="250" y2="206" stroke="white" stroke-width="1.8" opacity="0.5"/>
+ </g>
+ <circle cx="250" cy="200" r="28" fill="none" stroke="white" stroke-width="2" opacity="0.85"/>
+ <circle cx="250" cy="200" r="17" fill="none" stroke="white" stroke-width="1" opacity="0.3"/>
+ <circle cx="250" cy="200" r="9" :fill="`url(#${uid('hg')})`"/>
+ <circle cx="250" cy="200" r="3.5" fill="white" :opacity="0.5 + ci * 0.45"/>
+ <line x1="250" y1="112" x2="250" y2="288" stroke="white" stroke-width="1.5" opacity="0.4"/>
+ </g>
+
+ <!-- ══ DATABASE (RIGHT HALF) ══ -->
+ <g :transform="`translate(${splitX}, 0)`" :clip-path="`url(#${uid('cd')})`" :filter="`url(#${uid('gl')})`">
+ <circle cx="250" cy="200" r="88" fill="white" fill-opacity="0.03"/>
+ <path d="M250,112 A88,88 0 0,1 250,288" fill="none" stroke="white" stroke-width="2" opacity="0.85"/>
+ <path d="M250,118 Q370,118 370,138 Q370,156 250,156" fill="white" fill-opacity="0.06" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.9"/>
+ <path d="M250,156 Q370,156 370,174 Q370,192 250,192" fill="white" fill-opacity="0.04" stroke="white" stroke-width="1.4" stroke-linecap="round" opacity="0.6"/>
+ <path d="M250,192 Q370,192 370,210 Q370,228 250,228" fill="white" fill-opacity="0.04" stroke="white" stroke-width="1.4" stroke-linecap="round" opacity="0.6"/>
+ <path d="M250,228 Q370,228 370,246 Q370,264 250,264" fill="white" fill-opacity="0.04" stroke="white" stroke-width="1.4" stroke-linecap="round" opacity="0.6"/>
+ <path d="M250,264 Q370,264 370,282 Q370,298 250,298" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.9"/>
+ <line x1="370" y1="138" x2="370" y2="282" stroke="white" stroke-width="1.5" opacity="0.4"/>
+ <g stroke="white" stroke-width="0.9" opacity="0.2">
+ <line x1="258" y1="128" x2="365" y2="128"/><line x1="258" y1="136" x2="365" y2="136"/>
+ <line x1="258" y1="164" x2="366" y2="164"/><line x1="258" y1="172" x2="366" y2="172"/>
+ <line x1="258" y1="200" x2="366" y2="200"/><line x1="258" y1="208" x2="366" y2="208"/>
+ <line x1="258" y1="236" x2="365" y2="236"/><line x1="258" y1="244" x2="365" y2="244"/>
+ <line x1="258" y1="272" x2="362" y2="272"/><line x1="258" y1="279" x2="358" y2="279"/>
+ </g>
+ <circle cx="255" cy="132" r="3.5" fill="white" :opacity="0.45 + beat * 0.45"/>
+ <circle cx="255" cy="168" r="3.5" fill="white" :opacity="0.32 + beat * 0.38"/>
+ <circle cx="255" cy="204" r="3.5" fill="white" :opacity="0.45 + beat * 0.45"/>
+ <circle cx="255" cy="240" r="3.5" fill="white" :opacity="0.28 + beat * 0.34"/>
+ <circle cx="255" cy="275" r="3.5" fill="white" :opacity="0.22 + beat * 0.28"/>
+ <rect x="250" :y="scanY - 13" width="120" height="26" :fill="`url(#${uid('sc')})`" :opacity="0.25 + ci * 0.3"/>
+ <line x1="250" y1="118" x2="250" y2="298" stroke="white" stroke-width="1.5" opacity="0.4"/>
+ </g>
+
+ <!-- ══ CRACK ══ -->
+ <rect
+ :x="250 - 20 - splitX * 0.5" y="55"
+ :width="40 + splitX" height="290"
+ :fill="`url(#${uid('ca')})`" :filter="`url(#${uid('hl')})`"
+ />
+ <polyline :points="crackEdgeL" fill="none" stroke="white" stroke-width="0.9" :opacity="ci * 0.5"/>
+ <polyline :points="crackEdgeR" fill="none" stroke="white" stroke-width="0.9" :opacity="ci * 0.5"/>
+ <line
+ x1="250" y1="58" x2="250" y2="342"
+ :stroke="`url(#${uid('ck')})`"
+ :stroke-width="1.5 + ci * 2"
+ :filter="`url(#${uid('cb')})`"
+ />
+
+ <!-- ══ PARTICLES ══ -->
+ <g :filter="`url(#${uid('pg')})`">
+ <circle
+ v-for="p in particles" :key="p.id"
+ :cx="p.cx" :cy="p.cy" :r="p.r"
+ fill="white" :opacity="p.op"
+ />
+ <line
+ v-for="s in streaks" :key="'s' + s.id"
+ :x1="s.x1" :y1="s.y" :x2="s.x2" :y2="s.y"
+ stroke="white" stroke-width="1.2" :opacity="s.op"
+ />
+ </g>
+</svg>
+ </template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+
+const props = defineProps({
+size: { type: Number, default: 40 },
+speed: { type: Number, default: 1 },
+interactive: { type: Boolean, default: true },
+})
+
+const _id = Math.random().toString(36).slice(2, 7)
+const uid = (name) => `dg-${_id}-${name}`
+
+const t = ref(0)
+const hovered = ref(false)
+let lastTs = null
+let rafId = null
+
+const splitX = computed(() => {
+const phase = (t.value % 6.5) / 6.5
+const hScale = hovered.value ? 1.5 : 1.0
+return 60 * hScale * Math.pow(Math.sin(phase * Math.PI), 1.5)
+})
+const ci = computed(() => Math.pow(splitX.value / 60, 0.65))
+const gearAngle = computed(() => (t.value * (hovered.value ? 108 : 48)) % 360)
+const scanY = computed(() => 118 + ((t.value % 2.0) / 2.0) * 180)
+const beat = computed(() => (Math.sin(t.value * 2.6) + 1) / 2)
+
+const Y_STEPS = [62, 96, 130, 164, 200, 238, 275, 314, 342]
+const J_L = [-2, -6, 1, -8, -3, -9, 2, -7, -3]
+const J_R = [ 2, 6, -1, 8, 3, 9, -2, 7, 3]
+
+const crackEdgeL = computed(() =>
+Y_STEPS.map((y, i) => `${250 - splitX.value - 2 + splitX.value * 0.12 + J_L[i]},${y}`).join(' ')
+)
+const crackEdgeR = computed(() =>
+Y_STEPS.map((y, i) => `${250 + splitX.value + 2 + splitX.value * 0.12 + J_R[i]},${y}`).join(' ')
+)
+
+const PSEED = [
+{ yBase:148, side:-1, r:2.2 }, { yBase:178, side:-1, r:1.6 },
+{ yBase:224, side:-1, r:2.2 }, { yBase:260, side:-1, r:1.6 },
+{ yBase:116, side:-1, r:1.4 }, { yBase:310, side:-1, r:1.3 },
+{ yBase:200, side:-1, r:2.5 }, { yBase:170, side:-1, r:1.5 },
+{ yBase:148, side: 1, r:2.2 }, { yBase:185, side: 1, r:1.6 },
+{ yBase:222, side: 1, r:2.2 }, { yBase:260, side: 1, r:1.6 },
+{ yBase:118, side: 1, r:1.4 }, { yBase:308, side: 1, r:1.3 },
+{ yBase:200, side: 1, r:2.5 }, { yBase:170, side: 1, r:1.5 },
+]
+
+const particles = computed(() =>
+PSEED.map((s, i) => ({
+id: i,
+cx: 250 + (splitX.value + 8) * s.side,
+cy: s.yBase + Math.sin(t.value * 1.7 + i * 0.85) * 5,
+r: s.r,
+op: Math.max(0, ci.value * (0.4 + Math.sin(t.value * 2.1 + i) * 0.28)),
+}))
+)
+
+const SREFS = [
+{ x1b:240, x2b:232, y:200, sign:-1 }, { x1b:260, x2b:268, y:200, sign: 1 },
+{ x1b:242, x2b:234, y:162, sign:-1 }, { x1b:258, x2b:266, y:157, sign: 1 },
+{ x1b:242, x2b:234, y:240, sign:-1 }, { x1b:258, x2b:266, y:245, sign: 1 },
+]
+
+const streaks = computed(() =>
+SREFS.map((s, i) => {
+const stretch = splitX.value * 0.2
+return {
+id: i,
+x1: s.x1b + s.sign * splitX.value + s.sign * stretch * 0.5,
+x2: s.x2b + s.sign * splitX.value + s.sign * stretch,
+y: s.y,
+op: ci.value < 0.05 ? 0 : ci.value * (i < 2 ? 0.65 : 0.45),
+}
+})
+)
+
+function tick(ts) {
+if (lastTs === null) lastTs = ts
+const dt = Math.min((ts - lastTs) / 1000, 0.05)
+lastTs = ts
+t.value += dt * props.speed
+rafId = requestAnimationFrame(tick)
+}
+
+onMounted(() => { rafId = requestAnimationFrame(tick) })
+onUnmounted(() => { if (rafId) cancelAnimationFrame(rafId) })
+</script>
\ No newline at end of file
diff --git a/frontend/src/components/AppNavbar.vue b/frontend/src/components/AppNavbar.vue
--- a/frontend/src/components/AppNavbar.vue
+++ b/frontend/src/components/AppNavbar.vue
@@ -24,47 +24,72 @@
<template>
<nav class="bg-gray-900 text-white shadow-lg">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
- <div class="flex items-center justify-between h-14">
- <div class="flex items-center gap-4">
+ <div class="flex items-center h-14">
+
+ <!-- Left — flex-1 so it takes equal space -->
+ <div class="flex-1 flex items-center gap-4">
<a
- v-for="btn in config?.navbar?.buttons_left"
- :key="btn.name"
- :href="btn.link"
- class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
+ v-for="btn in config?.navbar?.buttons_left"
+ :key="btn.name"
+ :href="btn.link"
+ class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
{{ btn.name }}
</a>
</div>
- <RouterLink to="/" class="text-sm font-bold tracking-widest uppercase hover:text-gray-300 transition-colors">
- {{ config?.navbar?.title || 'ServPulse' }}
+ <!-- Centre — absolutely centered regardless of side widths -->
+ <RouterLink to="/" class="flex items-center gap-3 group">
+ <!-- Logo -->
+ <img
+ v-if="config?.navbar?.logo"
+ :src="config.navbar.logo"
+ alt="Logo"
+ class="h-8 w-auto object-contain group-hover:scale-105 transition-transform duration-200"
+ />
+ <!-- Vue logo if no custom logo -->
+ <svg
+ v-else
+ class="h-8 w-auto group-hover:scale-105 transition-transform duration-200"
+ viewBox="0 0 261.76 226.69"
+ >
+ <path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/>
+ <path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/>
+ </svg>
+
+ <!-- Title -->
+ <span class="text-sm font-bold tracking-widest uppercase text-white group-hover:text-gray-300 transition-colors">
+ {{ config?.navbar?.title || 'ServPulse' }}
+ </span>
</RouterLink>
- <div class="flex items-center gap-4">
+ <!-- Right — flex-1 so it takes equal space, content pushed to the right -->
+ <div class="flex-1 flex items-center justify-end gap-4">
<RouterLink
- v-if="isAuthenticated"
- to="/admin"
- class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
+ v-if="isAuthenticated"
+ to="/admin"
+ class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
Dashboard
</RouterLink>
<button
- v-if="isAuthenticated"
- @click="handleLogout"
- class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
+ v-if="isAuthenticated"
+ @click="handleLogout"
+ class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
Logout
</button>
<RouterLink
- v-else
- to="/admin/login"
- class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
+ v-else
+ to="/admin/login"
+ class="text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
Admin
</RouterLink>
-
</div>
+
</div>
</div>
</nav>
</template>
+
diff --git a/frontend/src/components/SubscribeForm.vue b/frontend/src/components/SubscribeForm.vue
--- a/frontend/src/components/SubscribeForm.vue
+++ b/frontend/src/components/SubscribeForm.vue
@@ -42,50 +42,53 @@
</script>
<template>
- <div class="card p-5">
- <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Subscribe to Updates</h3>
+ <div class="flex flex-col items-center w-full space-y-3">
- <div v-if="submitted" class="text-sm text-green-600 dark:text-green-400">
- <p v-if="type === 'email'">✓ Check your email to confirm your subscription.</p>
- <p v-else>✓ Webhook registered successfully.</p>
- </div>
+ <!-- Subscribe card -->
+ <div class="card p-6 w-full max-w-md text-center">
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Subscribe to Updates</h3>
- <form v-else @submit.prevent="handleSubmit" class="space-y-3">
- <div class="flex gap-4">
- <label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
- <input type="radio" v-model="type" value="email" class="text-brand-500" />
- Email
- </label>
- <label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
- <input type="radio" v-model="type" value="webhook" class="text-brand-500" />
- Webhook
- </label>
+ <div v-if="submitted" class="text-sm text-green-600 dark:text-green-400">
+ <p v-if="type === 'email'">✓ Check your email to confirm your subscription.</p>
+ <p v-else>✓ Webhook registered successfully.</p>
</div>
- <input
- v-if="type === 'email'"
- v-model="email"
- type="email"
- placeholder="you@example.com"
- class="input-field"
- required
- />
- <input
- v-else
- v-model="webhookUrl"
- type="url"
- placeholder="https://your-webhook-url.com/hook"
- class="input-field"
- required
- />
-
- <p v-if="error" class="text-sm text-red-500">{{ error }}</p>
-
- <button type="submit" class="btn-primary">Subscribe</button>
- </form>
- </div>
+ <form v-else @submit.prevent="handleSubmit" class="space-y-3">
+ <div class="flex justify-center gap-6">
+ <label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
+ <input type="radio" v-model="type" value="email" class="text-brand-500" />
+ Email
+ </label>
+ <label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
+ <input type="radio" v-model="type" value="webhook" class="text-brand-500" />
+ Webhook
+ </label>
+ </div>
+
+ <input
+ v-if="type === 'email'"
+ v-model="email"
+ type="email"
+ placeholder="you@example.com"
+ class="input-field"
+ required
+ />
+ <input
+ v-else
+ v-model="webhookUrl"
+ type="url"
+ placeholder="https://your-webhook-url.com/hook"
+ class="input-field"
+ required
+ />
+
+ <p v-if="error" class="text-sm text-red-500">{{ error }}</p>
- <div class="mt-3 text-center">
+ <button type="submit" class="btn-primary w-full justify-center">Subscribe</button>
+ </form>
+ </div>
+
+ <!-- Unsubscribe toggle -->
<button
type="button"
class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 underline"
@@ -94,7 +97,8 @@
Want to unsubscribe?
</button>
- <div v-if="showUnsubscribe" class="card p-5 mt-3">
+ <!-- Unsubscribe card -->
+ <div v-if="showUnsubscribe" class="card p-6 w-full max-w-md text-center">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Unsubscribe</h3>
<div v-if="unsubSubmitted" class="text-sm text-green-600 dark:text-green-400">
@@ -112,8 +116,9 @@
<p v-if="unsubError" class="text-sm text-red-500">{{ unsubError }}</p>
- <button type="submit" class="btn-primary">Unsubscribe</button>
+ <button type="submit" class="btn-primary w-full justify-center">Unsubscribe</button>
</form>
</div>
+
</div>
-</template>
+</template>
\ No newline at end of file
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -31,6 +31,11 @@
component: () => import('@/views/AdminDashboard.vue'),
meta: { requiresAuth: true },
},
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'NotFound',
+ component: () => import('@/views/NotFound.vue'),
+ },
],
})
diff --git a/frontend/src/views/NotFound.vue b/frontend/src/views/NotFound.vue
new file mode 100644
--- /dev/null
+++ b/frontend/src/views/NotFound.vue
@@ -0,0 +1,25 @@
+<template>
+ <div class="min-h-[70vh] flex items-center justify-center px-4">
+ <div class="card p-8 w-full max-w-md text-center">
+
+ <!-- 404 number -->
+ <p class="text-6xl font-bold text-brand-500 mb-4">404</p>
+
+ <!-- Headline -->
+ <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2">
+ The page you're looking for can't be found.
+ </h1>
+
+ <!-- Subtext -->
+ <p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
+ It may have been moved or deleted.
+ </p>
+
+ <!-- Actions -->
+ <RouterLink to="/" class="btn-primary flex items-center justify-center w-full">
+ Back to Home
+ </RouterLink>
+
+ </div>
+ </div>
+</template>
\ No newline at end of file

File Metadata

Mime Type
text/plain
Expires
Thu, Feb 26, 22:49 (20 h, 8 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3475489
Default Alt Text
D3971.id10317.diff (22 KB)

Event Timeline