feat(webapp): UI redesign with Technical Trust design system
- New design system (IBM Plex fonts, slate palette, dark mode) - Complete refactor of components and pages - Add favicon, PWA icons and new logo - Minor fixes (null handling, translations, navigation)
@@ -2,9 +2,29 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ackify - Gestion des signatures</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
|
||||
<!-- Apple Touch Icon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Web App Manifest -->
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="msapplication-TileColor" content="#0f172a" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
__META_TAGS__
|
||||
<script>
|
||||
window.ACKIFY_BASE_URL = '__ACKIFY_BASE_URL__';
|
||||
|
||||
BIN
webapp/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
webapp/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
webapp/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
webapp/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 608 B |
BIN
webapp/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
webapp/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
|
||||
stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 314 B |
75
webapp/public/icon-40x40.svg
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
sodipodi:docname="icon2.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showguides="true"
|
||||
inkscape:zoom="4.60625"
|
||||
inkscape:cx="-24.097693"
|
||||
inkscape:cy="23.12076"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="945"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg3" />
|
||||
<g
|
||||
transform="rotate(-20,23.113718,16.740549)"
|
||||
id="g2">
|
||||
<rect
|
||||
x="-24.957579"
|
||||
y="-11.865315"
|
||||
width="8"
|
||||
height="32"
|
||||
rx="4"
|
||||
fill="#7b61ff"
|
||||
id="rect1"
|
||||
transform="matrix(-0.76604444,-0.64278761,-0.64278761,0.76604444,0,0)" />
|
||||
<rect
|
||||
x="20.971819"
|
||||
y="14.86158"
|
||||
width="8"
|
||||
height="22"
|
||||
rx="4"
|
||||
fill="url(#paint0_linear_ackify_v3)"
|
||||
id="rect2"
|
||||
style="fill:url(#paint0_linear_ackify_v3)" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs3">
|
||||
<linearGradient
|
||||
id="paint0_linear_ackify_v3"
|
||||
x1="28"
|
||||
y1="14"
|
||||
x2="28"
|
||||
y2="36"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-3.0281812,0.86157951)">
|
||||
<stop
|
||||
stop-color="#32E6E2"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#35C7BD"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
75
webapp/public/logo.svg
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
sodipodi:docname="icon2.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showguides="true"
|
||||
inkscape:zoom="4.60625"
|
||||
inkscape:cx="73.161465"
|
||||
inkscape:cy="67.408412"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="945"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg3" />
|
||||
<g
|
||||
transform="matrix(3.2560208,-1.1339053,1.1850946,3.1153793,-25.933317,23.475147)"
|
||||
id="g2">
|
||||
<rect
|
||||
x="-24.957579"
|
||||
y="-11.865315"
|
||||
width="8"
|
||||
height="32"
|
||||
rx="4"
|
||||
fill="#7b61ff"
|
||||
id="rect1"
|
||||
transform="matrix(-0.76604444,-0.64278761,-0.64278761,0.76604444,0,0)" />
|
||||
<rect
|
||||
x="20.971819"
|
||||
y="14.86158"
|
||||
width="8"
|
||||
height="22"
|
||||
rx="4"
|
||||
fill="url(#paint0_linear_ackify_v3)"
|
||||
id="rect2"
|
||||
style="fill:url(#paint0_linear_ackify_v3)" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs3">
|
||||
<linearGradient
|
||||
id="paint0_linear_ackify_v3"
|
||||
x1="28"
|
||||
y1="14"
|
||||
x2="28"
|
||||
y2="36"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-3.0281812,0.86157951)">
|
||||
<stop
|
||||
stop-color="#32E6E2"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#35C7BD"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
21
webapp/public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Ackify",
|
||||
"short_name": "Ackify",
|
||||
"description": "Proof of Read. Compliance made simple.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#0f172a",
|
||||
"background_color": "#f8fafc",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
||||
@@ -24,18 +24,18 @@ const sizeClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return {
|
||||
icon: 'h-5 w-5',
|
||||
logo: 'w-6 h-6',
|
||||
text: 'text-base'
|
||||
}
|
||||
case 'lg':
|
||||
return {
|
||||
icon: 'h-10 w-10',
|
||||
logo: 'w-10 h-10',
|
||||
text: 'text-2xl'
|
||||
}
|
||||
case 'md':
|
||||
default:
|
||||
return {
|
||||
icon: 'h-8 w-8',
|
||||
logo: 'w-8 h-8',
|
||||
text: 'text-xl'
|
||||
}
|
||||
}
|
||||
@@ -43,30 +43,24 @@ const sizeClasses = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg
|
||||
:class="[sizeClasses.icon, 'text-primary']"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Logo icon -->
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Ackify"
|
||||
:class="[sizeClasses.logo, 'flex-shrink-0']"
|
||||
/>
|
||||
|
||||
<!-- Text -->
|
||||
<div v-if="showText" class="flex flex-col">
|
||||
<span
|
||||
:class="[sizeClasses.text, textClass || 'font-bold text-foreground']"
|
||||
:class="[sizeClasses.text, textClass || 'font-bold text-slate-900 dark:text-slate-50']"
|
||||
>
|
||||
Ackify
|
||||
</span>
|
||||
<span
|
||||
v-if="showVersion && appVersion"
|
||||
class="text-xs text-muted-foreground leading-none -mt-0.5"
|
||||
class="text-xs text-slate-500 dark:text-slate-400 leading-none -mt-0.5"
|
||||
>
|
||||
{{ appVersion }}
|
||||
</span>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<div class="sign-button-container">
|
||||
<div class="w-full">
|
||||
<!-- Sign Button -->
|
||||
<button
|
||||
v-if="!isSigned"
|
||||
@click="handleSign"
|
||||
:disabled="loading || disabled || !docId"
|
||||
:class="buttonClasses"
|
||||
:class="[
|
||||
'w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3 text-base font-medium rounded-lg transition-all min-h-[48px]',
|
||||
loading || disabled || !docId
|
||||
? 'bg-slate-300 dark:bg-slate-700 text-slate-500 dark:text-slate-400 cursor-not-allowed'
|
||||
: 'trust-gradient text-white hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 dark:focus:ring-offset-slate-900'
|
||||
]"
|
||||
type="button"
|
||||
>
|
||||
<!-- Spinner -->
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
class="animate-spin h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -22,62 +29,71 @@
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
/>
|
||||
</svg>
|
||||
<!-- Pen Icon -->
|
||||
<svg
|
||||
v-else
|
||||
class="-ml-1 mr-3 h-5 w-5"
|
||||
class="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
{{ loading ? $t('signButton.signing') : $t('signButton.confirmAction') }}
|
||||
<span>{{ loading ? $t('signButton.signing') : $t('signButton.confirmAction') }}</span>
|
||||
</button>
|
||||
|
||||
<div v-else class="signed-status">
|
||||
<div class="flex items-center justify-center space-x-2 text-green-700">
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold">{{ $t('signButton.confirmed') }}</span>
|
||||
<!-- Signed Status -->
|
||||
<div v-else class="bg-emerald-50 dark:bg-emerald-900/20 border-2 border-emerald-200 dark:border-emerald-800 rounded-xl p-4 sm:p-5">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full verified-gradient flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-center sm:text-left">
|
||||
<p class="font-semibold text-emerald-700 dark:text-emerald-400">
|
||||
{{ $t('signButton.confirmed') }}
|
||||
</p>
|
||||
<p v-if="signedAt" class="text-sm text-emerald-600 dark:text-emerald-500 mt-0.5">
|
||||
{{ $t('signButton.on') }} {{ formatDate(signedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="signedAt" class="mt-2 text-sm text-muted-foreground text-center">
|
||||
{{ $t('signButton.on') }} {{ formatDate(signedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-4 text-red-600 text-sm text-center">
|
||||
{{ error }}
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3">
|
||||
<p class="text-red-600 dark:text-red-400 text-sm text-center">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSignatureStore } from '@/stores/signatures'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -137,15 +153,6 @@ async function checkIfSigned() {
|
||||
// Watch for changes in signatures prop or auth state
|
||||
watch(() => [props.signatures, authStore.user], checkIfSigned, { immediate: true, deep: true })
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
return [
|
||||
'inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white transition-colors',
|
||||
loading.value || props.disabled || !props.docId
|
||||
? 'bg-indigo-400 cursor-not-allowed'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
]
|
||||
})
|
||||
|
||||
async function handleSign() {
|
||||
if (!props.docId) {
|
||||
error.value = t('signButton.error.missingDocId')
|
||||
@@ -205,16 +212,3 @@ function formatDate(dateString: string): string {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-button-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signed-status {
|
||||
padding: 1rem;
|
||||
background-color: #f0fdf4;
|
||||
border: 2px solid #86efac;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<div class="signature-list">
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<div class="w-full">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<svg
|
||||
class="animate-spin h-8 w-8 text-primary"
|
||||
class="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -15,142 +16,135 @@
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-else-if="signatures.length === 0" class="empty-state">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-muted-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="mt-2 text-muted-foreground">{{ emptyMessage || $t('signatureList.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="signatures.length === 0" class="text-center py-12 px-4">
|
||||
<div class="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-800 rounded-2xl flex items-center justify-center mb-4">
|
||||
<svg
|
||||
class="h-8 w-8 text-slate-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400">{{ emptyMessage || $t('signatureList.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Signatures List -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="signature in signatures"
|
||||
:key="signature.id"
|
||||
:class="[
|
||||
'signature-card shadow rounded-lg p-4 hover:shadow-md transition-shadow bg-card text-card-foreground border border-border',
|
||||
isDeleted ? 'opacity-50' : ''
|
||||
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 sm:p-5 hover:shadow-md transition-shadow',
|
||||
isDeleted ? 'opacity-60' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-lg font-medium text-foreground">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Title Row -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<h3 class="text-base sm:text-lg font-semibold text-slate-900 dark:text-white truncate">
|
||||
{{ signature.docTitle || signature.docId }}
|
||||
</h3>
|
||||
<!-- Status Badge -->
|
||||
<span
|
||||
v-if="!isDeleted"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 h-3 w-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ $t('signatureList.confirmed') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 text-xs font-medium rounded-full"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 h-3 w-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ $t('signatureList.documentDeleted') }}{{ signature.docDeletedAt ? ` ${formatDate(signature.docDeletedAt)}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="signature.docTitle">
|
||||
<span class="font-medium">{{ $t('signatureList.fields.id') }}</span> {{ signature.docId }}
|
||||
<!-- Info Grid -->
|
||||
<div class="space-y-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p v-if="signature.docTitle" class="flex items-start gap-2">
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.id') }}</span>
|
||||
<span class="font-mono text-slate-700 dark:text-slate-300 break-all">{{ signature.docId }}</span>
|
||||
</p>
|
||||
<p v-if="signature.docUrl">
|
||||
<span class="font-medium">{{ $t('signatureList.fields.document') }}</span>
|
||||
<a :href="signature.docUrl" target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded">
|
||||
<p v-if="signature.docUrl" class="flex items-start gap-2">
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.document') }}</span>
|
||||
<a :href="signature.docUrl" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline break-all">
|
||||
{{ signature.docUrl }}
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="showUserInfo">
|
||||
<span class="font-medium">{{ $t('signatureList.fields.reader') }}</span> {{ signature.userName || signature.userEmail }}
|
||||
<p v-if="showUserInfo" class="flex items-start gap-2">
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.reader') }}</span>
|
||||
<span class="text-slate-700 dark:text-slate-300">{{ signature.userName || signature.userEmail }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">{{ $t('signatureList.fields.date') }}</span> {{ formatDate(signature.signedAt) }}
|
||||
<p class="flex items-start gap-2">
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.date') }}</span>
|
||||
<span class="text-slate-700 dark:text-slate-300">{{ formatDate(signature.signedAt) }}</span>
|
||||
</p>
|
||||
<p v-if="signature.serviceInfo" class="flex items-center">
|
||||
<span class="font-medium mr-2">{{ $t('signatureList.fields.source') }}</span>
|
||||
<span class="inline-flex items-center space-x-1">
|
||||
<p v-if="signature.serviceInfo" class="flex items-start gap-2">
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-500 uppercase tracking-wide min-w-[60px]">{{ $t('signatureList.fields.source') }}</span>
|
||||
<span class="inline-flex items-center gap-1.5 text-slate-700 dark:text-slate-300">
|
||||
<span v-html="signature.serviceInfo.icon"></span>
|
||||
<span>{{ signature.serviceInfo.name }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showDetails" class="mt-3 pt-3 border-t border-border">
|
||||
<details class="text-xs text-muted-foreground">
|
||||
<summary class="cursor-pointer hover:text-foreground font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded">
|
||||
<!-- Verification Details -->
|
||||
<div v-if="showDetails" class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<details class="text-xs text-slate-500 dark:text-slate-400 group">
|
||||
<summary class="cursor-pointer hover:text-slate-700 dark:hover:text-slate-300 font-medium flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4 transition-transform group-open:rotate-90" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{{ $t('signatureList.verificationDetails') }}
|
||||
</summary>
|
||||
<div class="mt-2 space-y-1 font-mono bg-muted p-2 rounded border border-border">
|
||||
<p><span class="font-semibold">{{ $t('signatureList.fields.id') }}</span> {{ signature.id }}</p>
|
||||
<p><span class="font-semibold">{{ $t('signatureList.fields.nonce') }}</span> {{ signature.nonce }}</p>
|
||||
<div class="mt-3 accent-border bg-slate-50 dark:bg-slate-900/50 rounded-r-lg p-3 space-y-1.5 font-mono text-xs">
|
||||
<p><span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.id') }}</span> {{ signature.id }}</p>
|
||||
<p><span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.nonce') }}</span> {{ signature.nonce }}</p>
|
||||
<p class="break-all">
|
||||
<span class="font-semibold">{{ $t('signatureList.fields.hash') }}</span> {{ signature.payloadHash }}
|
||||
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.fields.hash') }}</span> {{ signature.payloadHash }}
|
||||
</p>
|
||||
<p class="break-all">
|
||||
<span class="font-semibold">{{ $t('signatureList.confirmation') }}</span>
|
||||
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.confirmation') }}</span>
|
||||
{{ signature.signature.substring(0, 64) }}...
|
||||
</p>
|
||||
<p v-if="signature.prevHash" class="break-all">
|
||||
<span class="font-semibold">{{ $t('signatureList.previousHash') }}</span> {{ signature.prevHash }}
|
||||
<span class="font-semibold text-slate-600 dark:text-slate-400">{{ $t('signatureList.previousHash') }}</span> {{ signature.prevHash }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showActions" class="ml-4">
|
||||
<!-- Actions -->
|
||||
<div v-if="showActions" class="flex-shrink-0">
|
||||
<button
|
||||
@click="$emit('view-details', signature)"
|
||||
class="text-primary hover:text-primary/80 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-2 py-1"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium px-3 py-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
>
|
||||
{{ $t('signatureList.viewDetails') }}
|
||||
</button>
|
||||
@@ -197,14 +191,3 @@ function formatDate(dateString: string): string {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signature-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,13 +8,15 @@ const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="mt-auto border-t border-border clay-card relative z-10">
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<footer class="mt-auto border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||||
<div class="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row sm:gap-6">
|
||||
<!-- Brand & Description -->
|
||||
<div class="flex items-center gap-3 text-center sm:text-left">
|
||||
<AppLogo size="sm" :show-text="true" :show-version="true" />
|
||||
<span class="text-sm text-muted-foreground leading-tight max-w-md">{{ t('footer.description') }}</span>
|
||||
<AppLogo size="sm" :show-text="false" />
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400 leading-tight max-w-md">
|
||||
{{ t('footer.description') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Links & Copyright -->
|
||||
@@ -23,16 +25,16 @@ const { t } = useI18n()
|
||||
href="https://github.com/btouchard/ackify-ce"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-muted-foreground hover:text-primary transition-colors"
|
||||
class="inline-flex items-center gap-1.5 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
:aria-label="t('footer.resources.documentation')"
|
||||
>
|
||||
<Github :size="16" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<span class="text-muted-foreground/50">•</span>
|
||||
<span class="text-slate-300 dark:text-slate-600">|</span>
|
||||
|
||||
<span class="text-muted-foreground whitespace-nowrap">
|
||||
<span class="text-slate-500 dark:text-slate-400 whitespace-nowrap">
|
||||
© {{ new Date().getFullYear() }} {{ t('footer.license') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Menu, X, ChevronDown, User, LogOut, Shield, FileSignature } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { Menu, X, ChevronDown, LogOut, Shield } from 'lucide-vue-next'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import LanguageSelect from './LanguageSelect.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
@@ -23,6 +22,21 @@ const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
// User initials for avatar
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value?.name && !user.value?.email) return '?'
|
||||
const name = user.value.name || user.value.email || ''
|
||||
const parts = name.split(/[\s@]+/).filter(p => p.length > 0)
|
||||
if (parts.length >= 2) {
|
||||
const first = parts[0] ?? ''
|
||||
const second = parts[1] ?? ''
|
||||
if (first.length > 0 && second.length > 0) {
|
||||
return (first.charAt(0) + second.charAt(0)).toUpperCase()
|
||||
}
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path
|
||||
}
|
||||
@@ -36,7 +50,7 @@ const toggleUserMenu = () => {
|
||||
}
|
||||
|
||||
const login = () => {
|
||||
router.push({name: 'auth-choice'})
|
||||
router.push({ name: 'auth-choice' })
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
@@ -44,35 +58,35 @@ const logout = async () => {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
|
||||
// Close user menu when clicking outside
|
||||
const closeUserMenu = () => {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-50 w-full border-b border-border/40 clay-card backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<nav class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" :aria-label="t('nav.mainNavigation')">
|
||||
<header class="sticky top-0 z-50 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
||||
<nav class="mx-auto max-w-6xl px-4 sm:px-6" :aria-label="t('nav.mainNavigation')">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<router-link to="/" class="focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md">
|
||||
<router-link to="/" class="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-lg">
|
||||
<AppLogo size="md" :show-version="true" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation (only visible when authenticated) -->
|
||||
<div v-if="isAuthenticated" class="hidden md:flex md:items-center md:space-x-6">
|
||||
<!-- Desktop Navigation -->
|
||||
<div v-if="isAuthenticated" class="hidden md:flex md:items-center md:space-x-1">
|
||||
<router-link
|
||||
to="/"
|
||||
:class="[
|
||||
'text-sm font-medium transition-colors hover:text-primary',
|
||||
isActive('/') ? 'text-primary' : 'text-muted-foreground'
|
||||
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
|
||||
isActive('/')
|
||||
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.home') }}
|
||||
@@ -81,44 +95,38 @@ const closeUserMenu = () => {
|
||||
<router-link
|
||||
to="/signatures"
|
||||
:class="[
|
||||
'text-sm font-medium transition-colors hover:text-primary',
|
||||
isActive('/signatures') ? 'text-primary' : 'text-muted-foreground'
|
||||
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
|
||||
isActive('/signatures')
|
||||
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.myConfirmations') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
:class="[
|
||||
'text-sm font-medium transition-colors hover:text-primary',
|
||||
isActive('/admin') ? 'text-primary' : 'text-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.admin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Language + Theme toggle + Auth -->
|
||||
<!-- Right side: Language + Theme + Auth -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<LanguageSelect />
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Desktop Auth -->
|
||||
<!-- Desktop Auth - User dropdown -->
|
||||
<div v-if="isAuthenticated" class="hidden md:block relative">
|
||||
<button
|
||||
@click="toggleUserMenu"
|
||||
class="flex items-center space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-accent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
class="flex items-center space-x-2 rounded-lg px-2 py-1.5 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="userMenuOpen"
|
||||
>
|
||||
<User :size="18" />
|
||||
<span class="text-foreground">{{ user?.email?.split('@')[0] }}</span>
|
||||
<ChevronDown :size="16" class="text-muted-foreground" />
|
||||
<!-- User avatar with initials -->
|
||||
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<span class="text-slate-700 dark:text-slate-200 hidden lg:inline">{{ user?.name || user?.email?.split('@')[0] }}</span>
|
||||
<ChevronDown :size="16" class="text-slate-400" />
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<!-- User dropdown menu -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
@@ -131,42 +139,34 @@ const closeUserMenu = () => {
|
||||
v-if="userMenuOpen"
|
||||
@click.stop
|
||||
v-click-outside="closeUserMenu"
|
||||
class="absolute right-0 mt-2 w-56 origin-top-right clay-card rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
class="absolute right-0 mt-2 w-56 origin-top-right bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-lg focus:outline-none"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground border-b border-border/40 mb-2">
|
||||
<p class="font-medium text-foreground">{{ user?.name }}</p>
|
||||
<p class="text-xs truncate">{{ user?.email }}</p>
|
||||
<!-- User info -->
|
||||
<div class="px-3 py-2 border-b border-slate-100 dark:border-slate-700 mb-2">
|
||||
<p class="font-medium text-slate-900 dark:text-slate-100">{{ user?.name }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 truncate">{{ user?.email }}</p>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
to="/signatures"
|
||||
@click="userMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<FileSignature :size="16" />
|
||||
<span>{{ t('nav.myConfirmations') }}</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Menu items -->
|
||||
<router-link
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
@click="userMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
class="flex items-center space-x-2 rounded-lg px-3 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Shield :size="16" />
|
||||
<span>{{ t('nav.administration') }}</span>
|
||||
</router-link>
|
||||
|
||||
<div class="border-t border-border/40 my-2"></div>
|
||||
<div v-if="isAdmin" class="border-t border-slate-100 dark:border-slate-700 my-2"></div>
|
||||
|
||||
<button
|
||||
@click="logout"
|
||||
class="flex w-full items-center space-x-2 rounded-md px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
class="flex w-full items-center space-x-2 rounded-lg px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut :size="16" />
|
||||
@@ -177,19 +177,24 @@ const closeUserMenu = () => {
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<Button v-else @click="login" variant="default" size="sm" class="hidden md:inline-flex">
|
||||
<!-- Login button (not authenticated) -->
|
||||
<button
|
||||
v-else
|
||||
@click="login"
|
||||
class="hidden md:inline-flex trust-gradient text-white font-medium rounded-lg px-4 py-2 text-sm hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{{ t('nav.login') }}
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
@click="toggleMobileMenu"
|
||||
class="md:hidden rounded-md p-2 hover:bg-accent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
class="md:hidden rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
:aria-label="t('nav.mobileMenu')"
|
||||
aria-expanded="false"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
>
|
||||
<Menu v-if="!mobileMenuOpen" :size="24" />
|
||||
<X v-else :size="24" />
|
||||
<Menu v-if="!mobileMenuOpen" :size="24" class="text-slate-600 dark:text-slate-300" />
|
||||
<X v-else :size="24" class="text-slate-600 dark:text-slate-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,16 +209,18 @@ const closeUserMenu = () => {
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div v-if="mobileMenuOpen" class="md:hidden border-t border-border/40">
|
||||
<div class="space-y-1 px-4 pb-3 pt-2">
|
||||
<!-- Navigation links (only when authenticated) -->
|
||||
<div v-if="mobileMenuOpen" class="md:hidden border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||||
<div class="space-y-1 px-4 pb-4 pt-2">
|
||||
<!-- Navigation links (authenticated) -->
|
||||
<template v-if="isAuthenticated">
|
||||
<router-link
|
||||
to="/"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'block rounded-md px-3 py-2 text-base font-medium transition-colors',
|
||||
isActive('/') ? 'bg-accent text-primary' : 'hover:bg-accent'
|
||||
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
|
||||
isActive('/')
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.home') }}
|
||||
@@ -223,8 +230,10 @@ const closeUserMenu = () => {
|
||||
to="/signatures"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'block rounded-md px-3 py-2 text-base font-medium transition-colors',
|
||||
isActive('/signatures') ? 'bg-accent text-primary' : 'hover:bg-accent'
|
||||
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
|
||||
isActive('/signatures')
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.myConfirmations') }}
|
||||
@@ -235,37 +244,40 @@ const closeUserMenu = () => {
|
||||
to="/admin"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'block rounded-md px-3 py-2 text-base font-medium transition-colors',
|
||||
isActive('/admin') ? 'bg-accent text-primary' : 'hover:bg-accent'
|
||||
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
|
||||
isActive('/admin') || route.path.startsWith('/admin')
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.administration') }}
|
||||
</router-link>
|
||||
|
||||
<div class="border-t border-border/40 pt-3 mt-3">
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground mb-2">
|
||||
<p class="font-medium text-foreground">{{ user?.name }}</p>
|
||||
<p class="text-xs">{{ user?.email }}</p>
|
||||
<!-- User section -->
|
||||
<div class="border-t border-slate-200 dark:border-slate-700 pt-3 mt-3">
|
||||
<div class="px-3 py-2 mb-2">
|
||||
<p class="font-medium text-slate-900 dark:text-slate-100">{{ user?.name }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ user?.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="logout"
|
||||
class="w-full text-left rounded-md px-3 py-2 text-base font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||
class="w-full text-left rounded-lg px-3 py-2.5 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
{{ t('nav.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Login button (when not authenticated) -->
|
||||
<Button v-else @click="login" variant="default" class="w-full">
|
||||
<!-- Login button (not authenticated) -->
|
||||
<button
|
||||
v-else
|
||||
@click="login"
|
||||
class="w-full trust-gradient text-white font-medium rounded-lg px-4 py-3 text-base hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{{ t('nav.login') }}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Click outside directive will be added via composable */
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@ import SkipToContent from '../accessibility/SkipToContent.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col bg-background relative">
|
||||
<div class="flex min-h-screen flex-col bg-slate-50 dark:bg-slate-900 relative">
|
||||
<SkipToContent />
|
||||
<AppHeader class="flex-shrink-0" />
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@ import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4',
|
||||
'relative w-full rounded-xl border p-4',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
success: 'border-l-4 border-green-400 bg-green-50 text-green-900 dark:bg-green-900/20 dark:text-green-400',
|
||||
destructive: 'border-l-4 border-red-400 bg-red-50 text-red-900 dark:bg-red-900/20 dark:text-red-400',
|
||||
warning: 'border-l-4 border-yellow-400 bg-yellow-50 text-yellow-900 dark:bg-yellow-900/20 dark:text-yellow-400',
|
||||
info: 'border-l-4 border-blue-400 bg-blue-50 text-blue-900 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
default: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white',
|
||||
success: 'border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400',
|
||||
destructive: 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400',
|
||||
warning: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400',
|
||||
info: 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -5,21 +5,17 @@ import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success:
|
||||
'border-transparent bg-green-500 text-white hover:bg-green-600',
|
||||
warning:
|
||||
'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
default: 'border-transparent bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900',
|
||||
secondary: 'border-transparent bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
|
||||
destructive: 'border-transparent bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400',
|
||||
outline: 'border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300',
|
||||
success: 'border-transparent bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400',
|
||||
warning: 'border-transparent bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
|
||||
info: 'border-transparent bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -6,24 +6,21 @@ import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer',
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 clay-button',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground clay-button',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default: 'trust-gradient text-white hover:opacity-90',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800',
|
||||
outline: 'border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||
secondary: 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 hover:bg-slate-200 dark:hover:bg-slate-600',
|
||||
ghost: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
|
||||
link: 'text-blue-600 dark:text-blue-400 underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
sm: 'h-9 rounded-lg px-3 text-sm',
|
||||
lg: 'h-11 rounded-lg px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ const props = defineProps<CardProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('clay-card rounded-lg text-card-foreground', props.class)">
|
||||
<div :class="cn('bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,7 @@ const handleInput = (event: Event) => {
|
||||
:value="modelValue"
|
||||
@input="handleInput"
|
||||
:class="cn(
|
||||
'flex h-10 w-full rounded-md clay-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-10 w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 px-4 py-2.5 text-sm text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
|
||||
props.class
|
||||
)"
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ const handleInput = (event: Event) => {
|
||||
:rows="rows"
|
||||
@input="handleInput"
|
||||
:class="cn(
|
||||
'flex min-h-[80px] w-full rounded-md clay-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[80px] w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 px-4 py-2.5 text-sm text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-none',
|
||||
props.class
|
||||
)"
|
||||
/>
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"previous": "Zurück",
|
||||
"skipToContent": "Zum Hauptinhalt springen"
|
||||
"skipToContent": "Zum Hauptinhalt springen",
|
||||
"refresh": "Aktualisieren"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"skipToContent": "Skip to main content"
|
||||
"skipToContent": "Skip to main content",
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
"back": "Volver",
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior",
|
||||
"skipToContent": "Ir al contenido principal"
|
||||
"skipToContent": "Ir al contenido principal",
|
||||
"refresh": "Actualizar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +515,7 @@
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"previous": "Précédent",
|
||||
"skipToContent": "Aller au contenu principal"
|
||||
"skipToContent": "Aller au contenu principal",
|
||||
"refresh": "Actualiser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
"back": "Indietro",
|
||||
"next": "Successivo",
|
||||
"previous": "Precedente",
|
||||
"skipToContent": "Vai al contenuto principale"
|
||||
"skipToContent": "Vai al contenuto principale",
|
||||
"refresh": "Aggiorna"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { Mail, LogIn, Loader2, AlertCircle, CheckCircle2 } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertTitle from '@/components/ui/AlertTitle.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
usePageTitle('auth.choice.title')
|
||||
@@ -121,110 +113,109 @@ function isValidEmail(email: string): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full box-border flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 sm:px-6 py-12">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Header with logo -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-foreground">
|
||||
<div class="flex justify-center mb-6">
|
||||
<AppLogo size="lg" :show-version="false" />
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{{ t('auth.choice.title') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('auth.choice.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert v-if="errorMessage" variant="destructive">
|
||||
<!-- Error Alert -->
|
||||
<div v-if="errorMessage" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div class="flex items-start">
|
||||
<AlertCircle :size="20" class="mr-3 mt-0.5" />
|
||||
<AlertCircle :size="20" class="mr-3 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<AlertTitle>{{ t('common.error') }}</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
<h3 class="font-medium text-red-900 dark:text-red-200">{{ t('common.error') }}</h3>
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-300">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<Alert v-if="magicLinkSent" variant="default" class="border-green-200 bg-green-50">
|
||||
<!-- Success Alert (Magic Link Sent) -->
|
||||
<div v-if="magicLinkSent" class="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4">
|
||||
<div class="flex items-start">
|
||||
<CheckCircle2 :size="20" class="mr-3 mt-0.5 text-green-600" />
|
||||
<CheckCircle2 :size="20" class="mr-3 mt-0.5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<AlertTitle class="text-green-800">{{ t('auth.magiclink.sent.title') }}</AlertTitle>
|
||||
<AlertDescription class="text-green-700">
|
||||
<h3 class="font-medium text-emerald-900 dark:text-emerald-200">{{ t('auth.magiclink.sent.title') }}</h3>
|
||||
<p class="mt-1 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{{ t('auth.magiclink.sent.message') }}
|
||||
<br>
|
||||
<span class="text-xs text-green-600">
|
||||
{{ t('auth.magiclink.sent.expire') }}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{{ t('auth.magiclink.sent.expire') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Login -->
|
||||
<Card v-if="oauthEnabled">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<LogIn class="h-5 w-5" />
|
||||
{{ t('auth.oauth.title') }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ t('auth.oauth.description') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
@click="loginWithOAuth"
|
||||
:disabled="loading"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Loader2 v-if="loading" class="h-4 w-4 animate-spin mr-2" />
|
||||
{{ t('auth.oauth.button') }}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<!-- OAuth Login Card -->
|
||||
<div v-if="oauthEnabled" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<LogIn :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('auth.oauth.title') }}</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('auth.oauth.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="loginWithOAuth"
|
||||
:disabled="loading"
|
||||
class="w-full trust-gradient text-white font-medium rounded-lg px-4 py-3 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<Loader2 v-if="loading" class="w-4 h-4 animate-spin mr-2" />
|
||||
{{ t('auth.oauth.button') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Magic Link Login -->
|
||||
<Card v-if="magicLinkEnabled">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Mail class="h-5 w-5" />
|
||||
{{ t('auth.magiclink.title') }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ t('auth.magiclink.description') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="requestMagicLink" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-foreground mb-1">
|
||||
{{ t('auth.magiclink.email_label') }}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
:disabled="loading"
|
||||
:placeholder="t('auth.magiclink.email_placeholder')"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-input text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
<!-- Magic Link Login Card -->
|
||||
<div v-if="magicLinkEnabled" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Mail :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('auth.magiclink.title') }}</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('auth.magiclink.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="requestMagicLink" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
{{ t('auth.magiclink.email_label') }}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
:disabled="loading"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
>
|
||||
<Loader2 v-if="loading" class="h-4 w-4 animate-spin mr-2" />
|
||||
<Mail v-else class="h-4 w-4 mr-2" />
|
||||
{{ t('auth.magiclink.button') }}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
:placeholder="t('auth.magiclink.email_placeholder')"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-3 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<Loader2 v-if="loading" class="w-4 h-4 animate-spin mr-2" />
|
||||
<Mail v-else class="w-4 h-4 mr-2" />
|
||||
{{ t('auth.magiclink.button') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
<!-- Privacy note -->
|
||||
<p class="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ t('auth.choice.privacy') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,55 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-background text-foreground p-4">
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-900 p-4 font-sans">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="bg-destructive/10 dark:bg-destructive/20 border border-destructive/50 rounded-lg p-4">
|
||||
<p class="text-destructive text-sm">{{ error }}</p>
|
||||
<div v-else-if="error" class="max-w-md mx-auto">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-red-700 dark:text-red-400 text-sm">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document info and signatures -->
|
||||
<div v-else-if="documentData" class="max-w-2xl mx-auto">
|
||||
<!-- Document header with signatures -->
|
||||
<div v-if="documentData.signatures.length > 0">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-foreground mb-2">
|
||||
{{ t('embed.document') }} {{ documentData.title }}
|
||||
</h2>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ t('embed.confirmationsCount', { count: documentData.signatures.length }) }}
|
||||
</span>
|
||||
<span v-if="documentData.metadata?.title">{{ documentData.metadata.title }}</span>
|
||||
<!-- Header Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 sm:p-5 mb-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg sm:text-xl font-bold text-slate-900 dark:text-white truncate">
|
||||
{{ documentData.title }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2 mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ t('embed.confirmationsCount', { count: documentData.signatures.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sign button -->
|
||||
<a
|
||||
:href="signUrl"
|
||||
target="_blank"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background text-sm font-medium whitespace-nowrap"
|
||||
class="inline-flex items-center justify-center gap-2 trust-gradient text-white font-medium rounded-lg px-5 py-2.5 hover:opacity-90 transition-opacity whitespace-nowrap min-h-[44px]"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{{ t('embed.sign') }}
|
||||
@@ -43,36 +57,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signatures list (compact) -->
|
||||
<!-- Signatures list -->
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="signature in documentData.signatures"
|
||||
:key="signature.id"
|
||||
class="bg-card text-card-foreground rounded-md px-3 py-2 border border-border flex items-center justify-between"
|
||||
class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex items-center space-x-2 min-w-0 flex-1">
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-foreground truncate">{{ signature.userEmail }}</span>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="w-8 h-8 rounded-full verified-gradient flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-900 dark:text-white truncate">{{ signature.userEmail }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap ml-2">{{ formatDateCompact(signature.signedAt) }}</span>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">{{ formatDateCompact(signature.signedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state - No signatures yet -->
|
||||
<div v-else class="text-center py-8">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-muted-foreground mb-4">{{ t('embed.noSignatures') }}</p>
|
||||
<div v-else class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-8 sm:p-12 text-center">
|
||||
<div class="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-700 rounded-2xl flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 mb-6">{{ t('embed.noSignatures') }}</p>
|
||||
<a
|
||||
:href="signUrl"
|
||||
target="_blank"
|
||||
class="inline-flex items-center px-6 py-3 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background text-base font-medium"
|
||||
class="inline-flex items-center justify-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-3 hover:opacity-90 transition-opacity min-h-[48px]"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
{{ t('embed.signDocument') }}
|
||||
@@ -80,12 +98,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer branding -->
|
||||
<div class="mt-8 pt-4 border-t border-border text-center">
|
||||
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700 text-center">
|
||||
<a
|
||||
href="https://github.com/btouchard/ackify-ce"
|
||||
target="_blank"
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-400 transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
{{ t('embed.poweredBy') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref, watch} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useSignatureStore} from '@/stores/signatures'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {usePageTitle} from '@/composables/usePageTitle'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSignatureStore } from '@/stores/signatures'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
|
||||
const {t} = useI18n()
|
||||
const { t } = useI18n()
|
||||
usePageTitle('sign.title')
|
||||
|
||||
import {AlertTriangle, CheckCircle2, FileText, Info, Users, Loader2, Shield, Zap, Clock} from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertTitle from '@/components/ui/AlertTitle.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { AlertTriangle, CheckCircle2, FileText, Info, Users, Loader2, Shield, Zap, Clock } from 'lucide-vue-next'
|
||||
import SignButton from '@/components/SignButton.vue'
|
||||
import SignatureList from '@/components/SignatureList.vue'
|
||||
import {documentService, type FindOrCreateDocumentResponse} from '@/services/documents'
|
||||
import {detectReference} from '@/services/referenceDetector'
|
||||
import {calculateFileChecksum} from '@/services/checksumCalculator'
|
||||
import {updateDocumentMetadata} from '@/services/admin'
|
||||
import DocumentForm from "@/components/DocumentForm.vue";
|
||||
import { documentService, type FindOrCreateDocumentResponse } from '@/services/documents'
|
||||
import { detectReference } from '@/services/referenceDetector'
|
||||
import { calculateFileChecksum } from '@/services/checksumCalculator'
|
||||
import { updateDocumentMetadata } from '@/services/admin'
|
||||
import DocumentForm from "@/components/DocumentForm.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -232,306 +223,286 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute left-1/4 top-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
<div class="absolute right-1/4 bottom-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[calc(100vh-8rem)]">
|
||||
<!-- Main Content -->
|
||||
<div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="mb-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
<h1 class="mb-2 text-2xl sm:text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
|
||||
{{ t('sign.title') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
<p class="text-base sm:text-lg text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message (shown independently of docId state) -->
|
||||
<!-- Error Message -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div v-if="errorMessage && !loadingDocument" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div class="flex items-start">
|
||||
<AlertTriangle :size="20" class="mr-3 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-red-900 dark:text-red-200">{{ t('sign.error.title') }}</h3>
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-300">{{ errorMessage }}</p>
|
||||
<div v-if="needsAuth" class="mt-4">
|
||||
<button
|
||||
@click="handleLoginClick"
|
||||
class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{{ t('sign.error.loginButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loadingDocument" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-12 text-center">
|
||||
<Loader2 :size="48" class="mx-auto mb-4 animate-spin text-blue-600" />
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-2">{{ t('sign.loading.title') }}</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.loading.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No Document: Show help message -->
|
||||
<div v-else-if="!docId" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-12 text-center">
|
||||
<div class="w-14 h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center mx-auto mb-4">
|
||||
<FileText :size="28" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-2">{{ t('sign.noDocument.title') }}</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400 mb-4 max-w-md mx-auto">
|
||||
{{ t('sign.noDocument.description', { code: '?doc=' }) }}
|
||||
</p>
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400 space-y-2 max-w-md mx-auto">
|
||||
<p class="font-medium text-slate-700 dark:text-slate-300">{{ t('sign.noDocument.examples') }}</p>
|
||||
<code class="block px-3 py-2 bg-slate-50 dark:bg-slate-900 rounded-lg text-xs font-mono text-slate-600 dark:text-slate-400">/?doc=https://example.com/policy.pdf</code>
|
||||
<code class="block px-3 py-2 bg-slate-50 dark:bg-slate-900 rounded-lg text-xs font-mono text-slate-600 dark:text-slate-400">/?doc=/path/to/document</code>
|
||||
<code class="block px-3 py-2 bg-slate-50 dark:bg-slate-900 rounded-lg text-xs font-mono text-slate-600 dark:text-slate-400">/?doc=my-unique-ref</code>
|
||||
|
||||
<!-- Document creation form -->
|
||||
<DocumentForm v-if="canCreateDocument" class="mt-6" />
|
||||
|
||||
<!-- Restricted message -->
|
||||
<div v-else class="mt-4 accent-border bg-amber-50 dark:bg-amber-900/20 rounded-r-lg p-4 text-left">
|
||||
<div class="flex items-start">
|
||||
<AlertTriangle :size="18" class="mr-3 mt-0.5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">{{ t('sign.documentCreation.restrictedToAdmins') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content when doc ID is present -->
|
||||
<div v-else-if="docId" class="space-y-6">
|
||||
<!-- Success Message -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<Alert v-if="errorMessage && !loadingDocument" variant="destructive" class="clay-card mb-6">
|
||||
<div class="flex items-start">
|
||||
<AlertTriangle :size="20" class="mr-3 mt-0.5"/>
|
||||
<div class="flex-1">
|
||||
<AlertTitle>{{ t('sign.error.title') }}</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
<div v-if="needsAuth" class="mt-4">
|
||||
<Button @click="handleLoginClick" variant="default">
|
||||
{{ t('sign.error.loginButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</transition>
|
||||
|
||||
<!-- Loading state -->
|
||||
<Card v-if="loadingDocument" class="clay-card">
|
||||
<CardContent class="py-12 text-center">
|
||||
<Loader2 :size="48" class="mx-auto mb-4 animate-spin text-primary"/>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ t('sign.loading.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('sign.loading.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- No Document: Show help message -->
|
||||
<Card v-else-if="!docId" class="clay-card">
|
||||
<CardContent class="py-12 text-center">
|
||||
<FileText :size="48" class="mx-auto mb-4 text-muted-foreground"/>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ t('sign.noDocument.title') }}</h2>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{{ t('sign.noDocument.description', { code: '?doc=' }) }}
|
||||
</p>
|
||||
<div class="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>{{ t('sign.noDocument.examples') }}</strong></p>
|
||||
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=https://example.com/policy.pdf</code>
|
||||
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=/path/to/document</code>
|
||||
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=my-unique-ref</code>
|
||||
<DocumentForm v-if="canCreateDocument" />
|
||||
<Alert v-else variant="warning" class="mt-4">
|
||||
<div class="flex items-start">
|
||||
<AlertTriangle :size="18" class="mr-3 mt-0.5"/>
|
||||
<div class="flex-1 text-sm">
|
||||
<p>{{ t('sign.documentCreation.restrictedToAdmins') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Main Content when doc ID is present -->
|
||||
<div v-else-if="docId" class="space-y-6">
|
||||
<!-- Success Message -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<Alert v-if="showSuccessMessage" variant="success" class="clay-card">
|
||||
<div v-if="showSuccessMessage" class="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4">
|
||||
<div class="flex items-start">
|
||||
<CheckCircle2 :size="20" class="mr-3 mt-0.5 text-green-600 dark:text-green-400"/>
|
||||
<CheckCircle2 :size="20" class="mr-3 mt-0.5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<AlertTitle>{{ t('sign.success.title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ t('sign.success.description') }}
|
||||
</AlertDescription>
|
||||
<h3 class="font-medium text-emerald-900 dark:text-emerald-200">{{ t('sign.success.title') }}</h3>
|
||||
<p class="mt-1 text-sm text-emerald-700 dark:text-emerald-300">{{ t('sign.success.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Document Info Card -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="rounded-lg bg-primary/10 p-3">
|
||||
<FileText :size="28" class="text-primary"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<CardTitle>
|
||||
{{ t('sign.document.title') }}<template v-if="currentDocument?.title"> : {{ currentDocument.title }}</template>
|
||||
</CardTitle>
|
||||
<CardDescription class="mt-2">
|
||||
<template v-if="currentDocument?.url">
|
||||
<a
|
||||
:href="currentDocument.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline font-mono text-xs"
|
||||
>
|
||||
{{ currentDocument.url }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-mono text-xs">{{ docId }}</span>
|
||||
</template>
|
||||
</CardDescription>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||
<!-- Header with icon -->
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="w-14 h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<FileText :size="28" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
{{ t('sign.document.title') }}<template v-if="currentDocument?.title"> : {{ currentDocument.title }}</template>
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
<template v-if="currentDocument?.url">
|
||||
<a
|
||||
:href="currentDocument.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-mono text-xs break-all"
|
||||
>
|
||||
{{ currentDocument.url }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-mono text-xs">{{ docId }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign Button -->
|
||||
<div class="pb-4">
|
||||
<SignButton
|
||||
:doc-id="docId"
|
||||
:signatures="documentSignatures"
|
||||
@signed="handleSigned"
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info Box (only shown if user hasn't signed yet) -->
|
||||
<div v-if="!userHasSigned" class="accent-border bg-blue-50 dark:bg-blue-900/20 rounded-r-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<Info :size="18" class="mr-3 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div class="flex-1 space-y-2 text-sm text-blue-800 dark:text-blue-200">
|
||||
<p>{{ t('sign.info.description') }}</p>
|
||||
<p class="font-medium">{{ t('sign.info.recorded') }}</p>
|
||||
<ul class="list-disc space-y-1 pl-5 text-blue-700 dark:text-blue-300">
|
||||
<li>{{ t('sign.info.email') }} : <strong class="text-blue-900 dark:text-blue-100">{{ user?.email }}</strong></li>
|
||||
<li>{{ t('sign.info.timestamp') }}</li>
|
||||
<li>{{ t('sign.info.signature') }}</li>
|
||||
<li>{{ t('sign.info.hash') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<!-- Sign Button Component -->
|
||||
<div class="pb-4">
|
||||
<SignButton
|
||||
:doc-id="docId"
|
||||
:signatures="documentSignatures"
|
||||
@signed="handleSigned"
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info Box (only shown if user hasn't signed yet) -->
|
||||
<Alert v-if="!userHasSigned" variant="info" class="border-l-4">
|
||||
<div class="flex items-start">
|
||||
<Info :size="18" class="mr-3 mt-0.5"/>
|
||||
<div class="flex-1 space-y-2 text-sm">
|
||||
<p>
|
||||
{{ t('sign.info.description') }}
|
||||
</p>
|
||||
<p class="font-medium">
|
||||
{{ t('sign.info.recorded') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<li>{{ t('sign.info.email') }} : <strong class="text-foreground">{{ user?.email }}</strong></li>
|
||||
<li>{{ t('sign.info.timestamp') }}</li>
|
||||
<li>{{ t('sign.info.signature') }}</li>
|
||||
<li>{{ t('sign.info.hash') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Confirmations -->
|
||||
<Card v-if="documentSignatures.length > 0" class="clay-card">
|
||||
<CardHeader>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2">
|
||||
<Users :size="20" class="text-primary"/>
|
||||
<div v-if="documentSignatures.length > 0" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Users :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{{ t('sign.confirmations.title') }}</CardTitle>
|
||||
<CardDescription>
|
||||
<h3 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.confirmations.title') }}</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.confirmations.count', { count: documentSignatures.length }, documentSignatures.length) }}
|
||||
{{ t('sign.confirmations.recorded', {}, documentSignatures.length) }}
|
||||
</CardDescription>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<!-- List -->
|
||||
<div class="p-6">
|
||||
<SignatureList
|
||||
:signatures="documentSignatures"
|
||||
:loading="loadingSignatures"
|
||||
:show-user-info="true"
|
||||
:show-details="true"
|
||||
:signatures="documentSignatures"
|
||||
:loading="loadingSignatures"
|
||||
:show-user-info="true"
|
||||
:show-details="true"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<Card v-else-if="!loadingSignatures" class="clay-card">
|
||||
<CardContent class="py-12 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Users :size="28" class="text-muted-foreground"/>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">
|
||||
{{ t('sign.empty.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.empty.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div v-else-if="!loadingSignatures" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-12 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
|
||||
<Users :size="28" class="text-slate-400" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{{ t('sign.empty.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.empty.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How it Works Section (always visible) -->
|
||||
<div class="mt-16 pt-12 border-t border-border/40">
|
||||
<!-- How it Works Section -->
|
||||
<div class="mt-16 pt-12 border-t border-slate-200 dark:border-slate-700">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="mb-3 text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
<h2 class="mb-3 text-xl sm:text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
{{ t('sign.howItWorks.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground max-w-2xl mx-auto">
|
||||
<p class="text-slate-500 dark:text-slate-400 max-w-2xl mx-auto">
|
||||
{{ t('sign.howItWorks.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Steps Grid -->
|
||||
<div class="grid gap-8 md:grid-cols-3 mb-12">
|
||||
<div class="grid gap-6 sm:gap-8 grid-cols-1 md:grid-cols-3 mb-12">
|
||||
<!-- Step 1 -->
|
||||
<Card class="clay-card-hover text-center">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileText :size="24" class="text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">{{ t('sign.howItWorks.step1.title') }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.step1.description', { code: '?doc=URL' }) }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50 dark:bg-blue-900/30">
|
||||
<FileText :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.howItWorks.step1.title') }}</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.howItWorks.step1.description', { code: '?doc=URL' }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<Card class="clay-card-hover text-center">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Shield :size="24" class="text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">{{ t('sign.howItWorks.step2.title') }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.step2.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50 dark:bg-blue-900/30">
|
||||
<Shield :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.howItWorks.step2.title') }}</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.howItWorks.step2.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<Card class="clay-card-hover text-center">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<CheckCircle2 :size="24" class="text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">{{ t('sign.howItWorks.step3.title') }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.step3.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 dark:bg-emerald-900/30">
|
||||
<CheckCircle2 :size="24" class="text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.howItWorks.step3.title') }}</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.howItWorks.step3.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="grid gap-6 grid-cols-1 md:grid-cols-3">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2 mt-1">
|
||||
<Shield :size="20" class="text-primary" />
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
|
||||
<Shield :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">{{ t('sign.howItWorks.features.crypto.title') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('sign.howItWorks.features.crypto.title') }}</h4>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.howItWorks.features.crypto.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2 mt-1">
|
||||
<Zap :size="20" class="text-primary" />
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
|
||||
<Zap :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">{{ t('sign.howItWorks.features.instant.title') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('sign.howItWorks.features.instant.title') }}</h4>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.howItWorks.features.instant.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2 mt-1">
|
||||
<Clock :size="20" class="text-primary" />
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
|
||||
<Clock :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">{{ t('sign.howItWorks.features.timestamp.title') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('sign.howItWorks.features.timestamp.title') }}</h4>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.howItWorks.features.timestamp.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,23 +2,47 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
usePageTitle('notFound.title')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full box-border bg-background text-foreground flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-6xl font-bold text-muted-foreground">404</h1>
|
||||
<p class="text-xl text-foreground mt-4">{{ t('notFound.title') }}</p>
|
||||
<p class="text-sm text-muted-foreground mt-2">{{ t('notFound.description') }}</p>
|
||||
<div class="min-h-full flex items-center justify-center px-4 py-16 sm:py-24">
|
||||
<div class="text-center max-w-md mx-auto">
|
||||
<!-- 404 with logo -->
|
||||
<div class="mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-slate-100 dark:bg-slate-800 mb-6">
|
||||
<span class="text-4xl font-bold text-slate-400 dark:text-slate-500">404</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white mb-3">
|
||||
{{ t('notFound.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-slate-500 dark:text-slate-400 mb-8 text-base sm:text-lg">
|
||||
{{ t('notFound.description') }}
|
||||
</p>
|
||||
|
||||
<!-- Back to home button -->
|
||||
<router-link
|
||||
to="/"
|
||||
class="mt-6 inline-block text-primary hover:text-primary/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-2 py-1"
|
||||
class="inline-flex items-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-3 hover:opacity-90 transition-opacity min-h-[48px]"
|
||||
>
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ t('notFound.home') }}
|
||||
</router-link>
|
||||
|
||||
<!-- Small logo at bottom -->
|
||||
<div class="mt-12 flex justify-center">
|
||||
<AppLogo size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSignatureStore } from '@/stores/signatures'
|
||||
import { FileSignature, FileCheck, Clock, Search, Info } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import { FileSignature, FileCheck, Clock, Search, Info, Loader2 } from 'lucide-vue-next'
|
||||
import SignatureList from '@/components/SignatureList.vue'
|
||||
import {usePageTitle} from "@/composables/usePageTitle.ts";
|
||||
import { usePageTitle } from "@/composables/usePageTitle"
|
||||
|
||||
const { t } = useI18n()
|
||||
usePageTitle('signatures.title')
|
||||
@@ -73,38 +65,32 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-[calc(100vh-4rem)]">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute left-1/3 top-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
<div class="absolute right-1/3 bottom-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[calc(100vh-8rem)]">
|
||||
<!-- Main Content -->
|
||||
<main class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<main class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
<h1 class="mb-2 text-2xl sm:text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
|
||||
{{ t('signatures.title') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
<p class="text-base sm:text-lg text-slate-500 dark:text-slate-400">
|
||||
{{ t('signatures.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Pills Mobile (compact horizontal full-width) -->
|
||||
<!-- Stats Pills Mobile -->
|
||||
<div class="sm:hidden mb-6 grid grid-cols-3 gap-3">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-primary/10 text-primary">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<FileSignature :size="18" />
|
||||
<span class="text-xl font-bold">{{ signatureStore.getUserSignaturesCount }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('signatures.stats.total') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<FileCheck :size="18" />
|
||||
<span class="text-xl font-bold">{{ uniqueDocumentsCount }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('signatures.stats.unique') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<Clock :size="18" />
|
||||
<span class="text-sm font-bold">{{ lastSignatureDate || t('signatures.stats.notAvailable') }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('signatures.stats.last') }}</span>
|
||||
@@ -114,107 +100,92 @@ onMounted(() => {
|
||||
<!-- Stats Cards Desktop -->
|
||||
<div class="hidden sm:grid mb-8 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Total Confirmations -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-primary/10 p-3">
|
||||
<FileSignature :size="24" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('signatures.stats.totalConfirmations') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">
|
||||
{{ signatureStore.getUserSignaturesCount }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<FileSignature :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">{{ t('signatures.stats.totalConfirmations') }}</p>
|
||||
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{{ signatureStore.getUserSignaturesCount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unique Documents -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-green-500/10 p-3">
|
||||
<FileCheck :size="24" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('signatures.stats.uniqueDocuments') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ uniqueDocumentsCount }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<FileCheck :size="24" class="text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">{{ t('signatures.stats.uniqueDocuments') }}</p>
|
||||
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ uniqueDocumentsCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Confirmation -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-blue-500/10 p-3">
|
||||
<Clock :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('signatures.stats.lastConfirmation') }}</p>
|
||||
<p class="text-lg font-semibold text-foreground">
|
||||
{{ lastSignatureDate || t('signatures.stats.notAvailable') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Clock :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">{{ t('signatures.stats.lastConfirmation') }}</p>
|
||||
<p class="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{{ lastSignatureDate || t('signatures.stats.notAvailable') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signatures List -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<!-- Signatures List Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{{ t('signatures.allConfirmations') }}</CardTitle>
|
||||
<CardDescription class="mt-2">
|
||||
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('signatures.allConfirmations') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('signatures.results', { count: filteredSignatures.length }) }}
|
||||
</CardDescription>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
<Search :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('signatures.search')"
|
||||
class="pl-10"
|
||||
class="w-full pl-10 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<!-- Loading -->
|
||||
<div v-if="signatureStore.loading" class="flex justify-center py-8">
|
||||
<svg
|
||||
class="animate-spin h-8 w-8 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<Loader2 :size="32" class="animate-spin text-blue-600" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredSignatures.length === 0" class="text-center py-8">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-muted-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="mt-2 text-muted-foreground">{{ t('signatures.empty.alternative') }}</p>
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredSignatures.length === 0" class="text-center py-12">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
|
||||
<FileSignature :size="28" class="text-slate-400" />
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400">{{ t('signatures.empty.alternative') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Signatures List -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Active signatures -->
|
||||
<SignatureList
|
||||
@@ -229,8 +200,8 @@ onMounted(() => {
|
||||
|
||||
<!-- Deleted documents section header -->
|
||||
<div v-if="deletedSignatures.length > 0" class="py-4">
|
||||
<hr v-if="activeSignatures.length > 0" class="border-border" />
|
||||
<p class="text-center text-sm text-muted-foreground mt-4 mb-2">
|
||||
<hr v-if="activeSignatures.length > 0" class="border-slate-200 dark:border-slate-700" />
|
||||
<p class="text-center text-sm text-slate-500 dark:text-slate-400 mt-4 mb-2">
|
||||
{{ t('signatures.deletedDocuments') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -246,21 +217,21 @@ onMounted(() => {
|
||||
:is-deleted="true"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<Alert variant="info" class="mt-6 clay-card border-l-4">
|
||||
<div class="mt-6 accent-border bg-blue-50 dark:bg-blue-900/20 rounded-r-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<Info :size="20" class="mr-3 mt-0.5" />
|
||||
<Info :size="20" class="mr-3 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<h3 class="mb-2 font-medium">{{ t('signatures.about.title') }}</h3>
|
||||
<AlertDescription>
|
||||
<h3 class="mb-2 font-medium text-blue-900 dark:text-blue-200">{{ t('signatures.about.title') }}</h3>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
{{ t('signatures.about.description') }}
|
||||
</AlertDescription>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -7,22 +7,21 @@ import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { listDocuments, type Document } from '@/services/admin'
|
||||
import { documentService } from '@/services/documents'
|
||||
import { extractError } from '@/services/http'
|
||||
import { FileText, Users, CheckCircle, ExternalLink, Settings, Loader2, Plus, Search, Webhook } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import Table from '@/components/ui/table/Table.vue'
|
||||
import TableHeader from '@/components/ui/table/TableHeader.vue'
|
||||
import TableBody from '@/components/ui/table/TableBody.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import TableHead from '@/components/ui/table/TableHead.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import {
|
||||
FileText,
|
||||
Users,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Loader2,
|
||||
Plus,
|
||||
Search,
|
||||
Webhook,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -155,371 +154,340 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-[calc(100vh-4rem)]">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute left-1/4 top-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
<div class="absolute right-1/4 bottom-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[calc(100vh-8rem)]">
|
||||
<!-- Main Content -->
|
||||
<main class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<main class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 flex items-start justify-between">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 class="mb-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
|
||||
{{ t('admin.title') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
<p class="mt-1 text-base text-slate-500 dark:text-slate-400">
|
||||
{{ t('admin.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
<router-link :to="{ name: 'admin-webhooks' }">
|
||||
<Button variant="outline">
|
||||
<Webhook :size="16" class="mr-2" />
|
||||
{{ t('admin.webhooks.manage') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link :to="{ name: 'admin-webhooks' }">
|
||||
<button class="inline-flex items-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<Webhook :size="16" />
|
||||
<span class="hidden sm:inline">{{ t('admin.webhooks.manage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<button
|
||||
@click="loadDocuments()"
|
||||
:disabled="loading || searching"
|
||||
class="inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw :size="16" :class="(loading || searching) ? 'animate-spin' : ''" />
|
||||
<span class="hidden sm:inline">{{ t('common.refresh') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div v-if="error" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div class="flex items-start">
|
||||
<AlertCircle :size="20" class="mr-3 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-red-900 dark:text-red-200">{{ t('common.error') }}</h3>
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Document Section -->
|
||||
<Card class="clay-card mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('admin.documents.new') }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ t('admin.documents.newDescription') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="createDocument">
|
||||
<!-- Desktop layout -->
|
||||
<div class="hidden md:flex flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label for="newDocId" class="block text-sm font-medium text-foreground mb-2">
|
||||
{{ t('admin.documents.idLabel') }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="newDocId"
|
||||
id="newDocId"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t('admin.documents.idPlaceholder')"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('admin.documents.idHelper') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pt-7">
|
||||
<Button type="submit" :disabled="!newDocId || creating">
|
||||
<FileText :size="16" class="mr-2" v-if="!creating" />
|
||||
<Loader2 :size="16" class="mr-2 animate-spin" v-else />
|
||||
{{ creating ? t('admin.documentForm.creating') : t('common.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layout with icon button -->
|
||||
<div class="md:hidden space-y-2">
|
||||
<label for="newDocIdMobile" class="block text-sm font-medium text-foreground">
|
||||
{{ t('admin.documents.idLabel') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="newDocId"
|
||||
id="newDocIdMobile"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t('admin.documents.idPlaceholder')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button type="submit" size="icon" :disabled="!newDocId || creating" class="shrink-0">
|
||||
<Loader2 :size="20" class="animate-spin" v-if="creating" />
|
||||
<Plus :size="20" v-else />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('admin.documents.idHelperShort') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<Alert v-if="error" variant="destructive" class="mb-6 clay-card">
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 mb-8">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Plus :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documents.new') }}</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documents.newDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="createDocument" class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="newDocId"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t('admin.documents.idPlaceholder')"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400 hidden sm:block">
|
||||
{{ t('admin.documents.idHelper') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!newDocId || creating"
|
||||
class="trust-gradient text-white font-medium rounded-lg px-6 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 sm:self-start"
|
||||
>
|
||||
<FileText v-if="!creating" :size="16" />
|
||||
<Loader2 v-else :size="16" class="animate-spin" />
|
||||
{{ creating ? t('admin.documentForm.creating') : t('common.confirm') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 :size="48" class="animate-spin text-primary" />
|
||||
<p class="mt-4 text-muted-foreground">{{ t('admin.loading') }}</p>
|
||||
<Loader2 :size="48" class="animate-spin text-blue-600" />
|
||||
<p class="mt-4 text-slate-500 dark:text-slate-400">{{ t('admin.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div v-else>
|
||||
<!-- KPI Pills Mobile (compact horizontal full-width) -->
|
||||
<!-- KPI Pills Mobile -->
|
||||
<div class="md:hidden mb-6 grid grid-cols-3 gap-3">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-primary/10 text-primary">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<FileText :size="18" />
|
||||
<span class="text-xl font-bold">{{ totalDocuments }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('admin.dashboard.stats.documents') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<Users :size="18" />
|
||||
<span class="text-xl font-bold">{{ totalSigners }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('admin.dashboard.stats.readers') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle :size="18" />
|
||||
<span class="text-xl font-bold">{{ activeDocuments }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('admin.dashboard.stats.active') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards Desktop (unchanged) -->
|
||||
<!-- KPI Cards Desktop -->
|
||||
<div class="hidden md:grid mb-8 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Total Documents -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-primary/10 p-3">
|
||||
<FileText :size="24" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('admin.dashboard.totalDocuments') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ totalDocuments }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<FileText :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.dashboard.totalDocuments') }}</p>
|
||||
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ totalDocuments }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Expected Readers -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-blue-500/10 p-3">
|
||||
<Users :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('admin.dashboard.stats.expected') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ totalSigners }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Users :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.dashboard.stats.expected') }}</p>
|
||||
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ totalSigners }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Documents -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-green-500/10 p-3">
|
||||
<CheckCircle :size="24" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('admin.documents.actions') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ activeDocuments }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||
<CheckCircle :size="24" class="text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documents.actions') }}</p>
|
||||
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ activeDocuments }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents Table -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('admin.documents.title') }}</CardTitle>
|
||||
<CardDescription class="mt-2">
|
||||
{{ t('admin.subtitle') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<!-- Documents List Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documents.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('admin.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<!-- Search Filter -->
|
||||
<div class="mb-6 relative">
|
||||
<Search v-if="!searching" :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Loader2 v-else :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground animate-spin" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.documents.search')"
|
||||
class="pl-10"
|
||||
/>
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search v-if="!searching" :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<Loader2 v-else :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 animate-spin" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.documents.search')"
|
||||
class="w-full pl-10 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop Table (hidden on mobile) -->
|
||||
<div v-if="documents.length > 0" class="hidden md:block rounded-md border border-border/40">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ t('admin.documents.document') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documents.url') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documents.createdOn') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documents.by') }}</TableHead>
|
||||
<TableHead class="text-right">{{ t('admin.documents.actions') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="doc in documents" :key="doc.docId">
|
||||
<TableCell>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<!-- Desktop Table -->
|
||||
<div v-if="documents.length > 0" class="hidden md:block overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-100 dark:border-slate-700">
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{{ t('admin.documents.document') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{{ t('admin.documents.url') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{{ t('admin.documents.createdOn') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{{ t('admin.documents.by') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{{ t('admin.documents.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tr
|
||||
v-for="doc in documents"
|
||||
:key="doc.docId"
|
||||
class="hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium text-foreground">{{ doc.title }}</div>
|
||||
<div class="text-xs font-mono text-muted-foreground">{{ doc.docId }}</div>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">{{ doc.title }}</div>
|
||||
<div class="text-xs font-mono text-slate-500 dark:text-slate-400">{{ doc.docId }}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<a
|
||||
v-if="doc.url"
|
||||
:href="doc.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center space-x-1 text-sm text-primary hover:underline"
|
||||
class="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<span class="max-w-[200px] truncate">{{ doc.url }}</span>
|
||||
<ExternalLink :size="14" />
|
||||
</a>
|
||||
<span v-else class="text-xs text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
<span v-else class="text-xs text-slate-400">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ formatDate(doc.createdAt) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
<span class="text-xs">{{ doc.createdBy }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<router-link
|
||||
:to="{ name: 'admin-document', params: { docId: doc.docId } }"
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings :size="16" class="mr-1" />
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">{{ doc.createdBy }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right">
|
||||
<router-link :to="{ name: 'admin-document', params: { docId: doc.docId } }">
|
||||
<button class="inline-flex items-center gap-1 text-sm text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
<Settings :size="16" />
|
||||
{{ t('admin.documents.manage') }}
|
||||
</Button>
|
||||
</button>
|
||||
</router-link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards (hidden on desktop) -->
|
||||
<!-- Mobile Cards -->
|
||||
<div v-if="documents.length > 0" class="md:hidden space-y-4">
|
||||
<Card v-for="doc in documents" :key="doc.docId" class="clay-card-hover">
|
||||
<CardContent class="p-4">
|
||||
<!-- Document Title & ID -->
|
||||
<div class="mb-3">
|
||||
<h3 class="font-medium text-foreground text-base">{{ doc.title }}</h3>
|
||||
<p class="text-xs font-mono text-muted-foreground mt-1">{{ doc.docId }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.docId"
|
||||
class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4"
|
||||
>
|
||||
<!-- Document Title & ID -->
|
||||
<div class="mb-3">
|
||||
<h3 class="font-medium text-slate-900 dark:text-slate-100">{{ doc.title }}</h3>
|
||||
<p class="text-xs font-mono text-slate-500 dark:text-slate-400 mt-1">{{ doc.docId }}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div v-if="doc.url" class="mb-3">
|
||||
<a
|
||||
:href="doc.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center space-x-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink :size="14" />
|
||||
<span class="truncate max-w-[250px]">{{ doc.url }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<!-- URL -->
|
||||
<div v-if="doc.url" class="mb-3">
|
||||
<a
|
||||
:href="doc.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<ExternalLink :size="14" />
|
||||
<span class="truncate max-w-[250px]">{{ doc.url }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-muted-foreground mb-3">
|
||||
<div class="flex items-center space-x-1">
|
||||
<FileText :size="14" />
|
||||
<span>{{ formatDate(doc.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<Users :size="14" />
|
||||
<span class="text-xs">{{ doc.createdBy }}</span>
|
||||
</div>
|
||||
<!-- Meta Info -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-slate-500 dark:text-slate-400 mb-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<FileText :size="14" />
|
||||
<span>{{ formatDate(doc.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Users :size="14" />
|
||||
<span class="text-xs">{{ doc.createdBy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-2 border-t border-border/40">
|
||||
<router-link
|
||||
:to="{ name: 'admin-document', params: { docId: doc.docId } }"
|
||||
class="flex-1"
|
||||
>
|
||||
<Button variant="outline" size="sm" class="w-full">
|
||||
<Settings :size="16" class="mr-2" />
|
||||
{{ t('admin.documents.manage') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-3 border-t border-slate-200 dark:border-slate-600">
|
||||
<router-link
|
||||
:to="{ name: 'admin-document', params: { docId: doc.docId } }"
|
||||
class="flex-1"
|
||||
>
|
||||
<button class="w-full inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<Settings :size="16" />
|
||||
{{ t('admin.documents.manage') }}
|
||||
</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-12">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<FileText :size="28" class="text-muted-foreground" />
|
||||
<div v-if="documents.length === 0" class="text-center py-12">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
|
||||
<FileText :size="28" class="text-slate-400" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">
|
||||
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{{ searchQuery ? t('admin.documents.noResults') : t('admin.documents.noDocuments') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ searchQuery ? t('admin.documents.tryAnotherSearch') : t('admin.documents.willAppear') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination (now works with search too!) -->
|
||||
<div v-if="documents.length > 0 && totalPages > 1" class="flex items-center justify-between mt-6 pt-4 border-t border-border/40">
|
||||
<!-- Mobile Pagination -->
|
||||
<div class="md:hidden flex items-center justify-between w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<!-- Pagination -->
|
||||
<div v-if="documents.length > 0 && totalPages > 1" class="flex items-center justify-between mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400 hidden md:block">
|
||||
{{ t('admin.documents.totalCount', totalDocuments) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 w-full md:w-auto justify-between md:justify-end">
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
@click="prevPage"
|
||||
class="inline-flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft :size="16" />
|
||||
{{ t('common.previous') }}
|
||||
</Button>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
</button>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('admin.documents.pagination.page', { current: currentPage, total: totalPages }) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<button
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="nextPage"
|
||||
class="inline-flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Pagination -->
|
||||
<div class="hidden md:flex items-center justify-between w-full">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ t('admin.documents.totalCount', totalDocuments) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage === 1"
|
||||
@click="prevPage"
|
||||
>
|
||||
{{ t('common.previous') }}
|
||||
</Button>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('admin.documents.pagination.pageOf', { current: currentPage, total: totalPages }) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="nextPage"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ChevronRight :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { availableWebhookEvents, createWebhook, getWebhook, updateWebhook, type WebhookInput, type Webhook } from '@/services/webhooks'
|
||||
import { extractError } from '@/services/http'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Textarea from '@/components/ui/Textarea.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import { Loader2, Save, ArrowLeft } from 'lucide-vue-next'
|
||||
import { Loader2, Save, ArrowLeft, Webhook as WebhookIcon } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -97,62 +87,157 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">{{ isNew ? t('admin.webhooks.new') : t('admin.webhooks.editTitle') }}</h1>
|
||||
<Button variant="outline" @click="goBack"><ArrowLeft :size="16" class="mr-2"/> {{ t('common.back') || 'Retour' }}</Button>
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<WebhookIcon class="w-6 h-6 sm:w-7 sm:h-7 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{{ isNew ? t('admin.webhooks.new') : t('admin.webhooks.editTitle') }}
|
||||
</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">{{ t('admin.webhooks.form.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="goBack"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 font-medium rounded-lg px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors min-h-[44px]"
|
||||
>
|
||||
<ArrowLeft :size="18" />
|
||||
{{ t('common.back') || 'Retour' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" variant="destructive" class="mb-4">
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('admin.webhooks.form.title') }}</CardTitle>
|
||||
<CardDescription>{{ t('admin.webhooks.form.subtitle') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="loading" class="flex items-center gap-3 py-10">
|
||||
<Loader2 :size="24" class="animate-spin" />
|
||||
<span>{{ t('admin.loading') }}</span>
|
||||
<!-- Error Alert -->
|
||||
<div v-if="error" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<form v-else @submit.prevent="save" class="space-y-5">
|
||||
<p class="text-red-700 dark:text-red-400 text-sm">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<!-- Card Header -->
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="font-semibold text-slate-900 dark:text-white">{{ t('admin.webhooks.form.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-3 py-12">
|
||||
<Loader2 :size="24" class="animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ t('admin.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form v-else @submit.prevent="save" class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ t('admin.webhooks.form.nameLabel') }}</label>
|
||||
<Input v-model="title" type="text" required :placeholder="t('admin.webhooks.form.namePlaceholder')" />
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.webhooks.form.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="t('admin.webhooks.form.namePlaceholder')"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ t('admin.webhooks.form.urlLabel') }}</label>
|
||||
<Input v-model="targetUrl" type="url" required placeholder="https://example.com/webhook" />
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.webhooks.form.urlLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="targetUrl"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/webhook"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Secret -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ t('admin.webhooks.form.secretLabel') }}</label>
|
||||
<Input v-model="secret" :type="isNew ? 'text' : 'password'" :placeholder="isNew ? t('admin.webhooks.form.secretPlaceholder') : t('admin.webhooks.form.secretKeep')" :required="isNew" />
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.webhooks.form.secretLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="secret"
|
||||
:type="isNew ? 'text' : 'password'"
|
||||
:placeholder="isNew ? t('admin.webhooks.form.secretPlaceholder') : t('admin.webhooks.form.secretKeep')"
|
||||
:required="isNew"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Events -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ t('admin.webhooks.form.eventsLabel') }}</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label v-for="e in availableWebhookEvents" :key="e.key" class="flex items-center gap-2">
|
||||
<input type="checkbox" :value="e.key" :checked="events.includes(e.key)" @change="toggleEvent(e.key)" />
|
||||
<span>{{ t(e.labelKey) }}</span>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||
{{ t('admin.webhooks.form.eventsLabel') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label
|
||||
v-for="e in availableWebhookEvents"
|
||||
:key="e.key"
|
||||
:class="[
|
||||
'flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors',
|
||||
events.includes(e.key)
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700/50'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="e.key"
|
||||
:checked="events.includes(e.key)"
|
||||
@change="toggleEvent(e.key)"
|
||||
class="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span :class="events.includes(e.key) ? 'text-blue-700 dark:text-blue-400 font-medium' : 'text-slate-700 dark:text-slate-300'">
|
||||
{{ t(e.labelKey) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ t('admin.webhooks.form.descriptionLabel') }}</label>
|
||||
<Textarea v-model="description" :placeholder="t('admin.webhooks.form.descriptionPlaceholder')" />
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ t('admin.webhooks.form.descriptionLabel') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="3"
|
||||
:placeholder="t('admin.webhooks.form.descriptionPlaceholder')"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<Button type="submit" :disabled="saving">
|
||||
<Loader2 v-if="saving" :size="16" class="mr-2 animate-spin" />
|
||||
<Save v-else :size="16" class="mr-2" />
|
||||
<!-- Submit -->
|
||||
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-2.5 hover:opacity-90 transition-opacity disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
<Loader2 v-if="saving" :size="18" class="animate-spin" />
|
||||
<Save v-else :size="18" />
|
||||
{{ t('common.save') || 'Enregistrer' }}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,21 +5,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { listWebhooks, deleteWebhook, toggleWebhook, type Webhook } from '@/services/webhooks'
|
||||
import { extractError } from '@/services/http'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Table from '@/components/ui/table/Table.vue'
|
||||
import TableHeader from '@/components/ui/table/TableHeader.vue'
|
||||
import TableBody from '@/components/ui/table/TableBody.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import TableHead from '@/components/ui/table/TableHead.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import { Loader2, Plus, Pencil, Trash2, ToggleLeft, ToggleRight, BadgeCheck } from 'lucide-vue-next'
|
||||
import { Loader2, Plus, Pencil, Trash2, ToggleLeft, ToggleRight, BadgeCheck, Webhook as WebhookIcon } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -35,7 +21,7 @@ async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const resp = await listWebhooks()
|
||||
items.value = resp.data
|
||||
items.value = resp.data || []
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
} finally {
|
||||
@@ -71,7 +57,8 @@ async function onToggle(id: number, enable: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatEvents(evts: string[]): string[] {
|
||||
function formatEvents(evts: string[] | null | undefined): string[] {
|
||||
if (!evts || !Array.isArray(evts)) return []
|
||||
return evts.map(e => t(`admin.webhooks.eventsMap.${e}`, e))
|
||||
}
|
||||
|
||||
@@ -79,89 +66,211 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ t('admin.webhooks.title') }}</h1>
|
||||
<p class="text-muted-foreground">{{ t('admin.webhooks.subtitle') }}</p>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<WebhookIcon class="w-6 h-6 sm:w-7 sm:h-7 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white">{{ t('admin.webhooks.title') }}</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">{{ t('admin.webhooks.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="gotoNew">
|
||||
<Plus :size="16" class="mr-2" />
|
||||
<button
|
||||
@click="gotoNew"
|
||||
class="w-full sm:w-auto inline-flex items-center justify-center gap-2 trust-gradient text-white font-medium rounded-lg px-4 py-2.5 hover:opacity-90 transition-opacity min-h-[44px]"
|
||||
>
|
||||
<Plus :size="18" />
|
||||
{{ t('admin.webhooks.new') }}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" variant="destructive" class="mb-4">
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
<!-- Error Alert -->
|
||||
<div v-if="error" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-red-700 dark:text-red-400 text-sm">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('admin.webhooks.listTitle') }}</CardTitle>
|
||||
<CardDescription>{{ t('admin.webhooks.listSubtitle') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="loading" class="flex items-center gap-3 py-10">
|
||||
<Loader2 :size="24" class="animate-spin" />
|
||||
<span>{{ t('admin.loading') }}</span>
|
||||
<!-- Main Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<!-- Card Header -->
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="font-semibold text-slate-900 dark:text-white">{{ t('admin.webhooks.listTitle') }}</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">{{ t('admin.webhooks.listSubtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-3 py-12">
|
||||
<Loader2 :size="24" class="animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span class="text-slate-500 dark:text-slate-400">{{ t('admin.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else>
|
||||
<div v-if="items.length > 0" class="rounded-md border border-border/40 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ t('admin.webhooks.columns.title') }}</TableHead>
|
||||
<TableHead>{{ t('admin.webhooks.columns.url') }}</TableHead>
|
||||
<TableHead>{{ t('admin.webhooks.columns.events') }}</TableHead>
|
||||
<TableHead>{{ t('admin.webhooks.columns.status') }}</TableHead>
|
||||
<TableHead class="text-right">{{ t('admin.webhooks.columns.actions') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="wh in items" :key="wh.id">
|
||||
<TableCell>
|
||||
<div class="font-medium">{{ wh.title || '-' }}</div>
|
||||
<div v-if="wh.description" class="text-xs text-muted-foreground">{{ wh.description }}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a :href="wh.targetUrl" target="_blank" class="text-primary hover:underline">{{ wh.targetUrl }}</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<!-- Desktop Table -->
|
||||
<div v-if="items.length > 0" class="hidden md:block overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.webhooks.columns.title') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.webhooks.columns.url') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.webhooks.columns.events') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.webhooks.columns.status') }}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.webhooks.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tr v-for="wh in items" :key="wh.id" class="hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<td class="px-4 py-4">
|
||||
<div class="font-medium text-slate-900 dark:text-white">{{ wh.title || '-' }}</div>
|
||||
<div v-if="wh.description" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{{ wh.description }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<a :href="wh.targetUrl" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline text-sm font-mono break-all">{{ wh.targetUrl }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="e in formatEvents(wh.events)" :key="e" class="px-2 py-0.5 text-xs rounded bg-muted">{{ e }}</span>
|
||||
<span v-for="e in formatEvents(wh.events)" :key="e" class="px-2 py-0.5 text-xs rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">{{ e }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span v-if="wh.active" class="inline-flex items-center text-green-600"><BadgeCheck :size="16" class="mr-1"/>{{ t('admin.webhooks.status.enabled') }}</span>
|
||||
<span v-else class="inline-flex items-center text-muted-foreground">{{ t('admin.webhooks.status.disabled') }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<span v-if="wh.active" class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full">
|
||||
<BadgeCheck :size="14" />
|
||||
{{ t('admin.webhooks.status.enabled') }}
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400 text-xs font-medium rounded-full">
|
||||
{{ t('admin.webhooks.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" @click="gotoEdit(wh.id)">
|
||||
<Pencil :size="14" class="mr-1" /> {{ t('admin.webhooks.edit') }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="onToggle(wh.id, !wh.active)" :disabled="toggling===wh.id">
|
||||
<Loader2 v-if="toggling===wh.id" :size="14" class="mr-1 animate-spin" />
|
||||
<ToggleRight v-else-if="!wh.active" :size="14" class="mr-1" />
|
||||
<ToggleLeft v-else :size="14" class="mr-1" />
|
||||
<button
|
||||
@click="gotoEdit(wh.id)"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Pencil :size="14" />
|
||||
{{ t('admin.webhooks.edit') }}
|
||||
</button>
|
||||
<button
|
||||
@click="onToggle(wh.id, !wh.active)"
|
||||
:disabled="toggling === wh.id"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Loader2 v-if="toggling === wh.id" :size="14" class="animate-spin" />
|
||||
<ToggleRight v-else-if="!wh.active" :size="14" />
|
||||
<ToggleLeft v-else :size="14" />
|
||||
{{ wh.active ? t('admin.webhooks.disable') : t('admin.webhooks.enable') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" @click="onDelete(wh.id)" :disabled="deleting===wh.id">
|
||||
<Loader2 v-if="deleting===wh.id" :size="14" class="mr-1 animate-spin" />
|
||||
<Trash2 v-else :size="14" class="mr-1" />
|
||||
</button>
|
||||
<button
|
||||
@click="onDelete(wh.id)"
|
||||
:disabled="deleting === wh.id"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-600 dark:text-red-400 bg-white dark:bg-slate-800 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Loader2 v-if="deleting === wh.id" :size="14" class="animate-spin" />
|
||||
<Trash2 v-else :size="14" />
|
||||
{{ t('admin.webhooks.delete') }}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards -->
|
||||
<div v-if="items.length > 0" class="md:hidden space-y-4">
|
||||
<div
|
||||
v-for="wh in items"
|
||||
:key="wh.id"
|
||||
class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-medium text-slate-900 dark:text-white truncate">{{ wh.title || '-' }}</h3>
|
||||
<p v-if="wh.description" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{{ wh.description }}</p>
|
||||
</div>
|
||||
<span v-if="wh.active" class="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 text-xs font-medium rounded-full flex-shrink-0">
|
||||
<BadgeCheck :size="12" />
|
||||
{{ t('admin.webhooks.status.enabled') }}
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center px-2 py-0.5 bg-slate-200 dark:bg-slate-600 text-slate-500 dark:text-slate-400 text-xs font-medium rounded-full flex-shrink-0">
|
||||
{{ t('admin.webhooks.status.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div class="mb-3">
|
||||
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">{{ t('admin.webhooks.columns.url') }}</p>
|
||||
<a :href="wh.targetUrl" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline text-sm font-mono break-all">{{ wh.targetUrl }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Events -->
|
||||
<div class="mb-4">
|
||||
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">{{ t('admin.webhooks.columns.events') }}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="e in formatEvents(wh.events)" :key="e" class="px-2 py-0.5 text-xs rounded-full bg-white dark:bg-slate-600 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-500">{{ e }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-2 pt-3 border-t border-slate-200 dark:border-slate-600">
|
||||
<button
|
||||
@click="gotoEdit(wh.id)"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors min-h-[44px]"
|
||||
>
|
||||
<Pencil :size="14" />
|
||||
{{ t('admin.webhooks.edit') }}
|
||||
</button>
|
||||
<button
|
||||
@click="onToggle(wh.id, !wh.active)"
|
||||
:disabled="toggling === wh.id"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
<Loader2 v-if="toggling === wh.id" :size="14" class="animate-spin" />
|
||||
<ToggleRight v-else-if="!wh.active" :size="14" />
|
||||
<ToggleLeft v-else :size="14" />
|
||||
{{ wh.active ? t('admin.webhooks.disable') : t('admin.webhooks.enable') }}
|
||||
</button>
|
||||
<button
|
||||
@click="onDelete(wh.id)"
|
||||
:disabled="deleting === wh.id"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 bg-white dark:bg-slate-800 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
<Loader2 v-if="deleting === wh.id" :size="14" class="animate-spin" />
|
||||
<Trash2 v-else :size="14" />
|
||||
{{ t('admin.webhooks.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="items.length === 0" class="py-12 text-center">
|
||||
<div class="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-700 rounded-2xl flex items-center justify-center mb-4">
|
||||
<WebhookIcon class="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400">{{ t('admin.webhooks.empty') }}</p>
|
||||
<button
|
||||
@click="gotoNew"
|
||||
class="mt-4 inline-flex items-center gap-2 trust-gradient text-white font-medium rounded-lg px-4 py-2.5 hover:opacity-90 transition-opacity min-h-[44px]"
|
||||
>
|
||||
<Plus :size="18" />
|
||||
{{ t('admin.webhooks.new') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="py-10 text-center text-muted-foreground">{{ t('admin.webhooks.empty') }}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,87 +1,82 @@
|
||||
@import "tailwindcss";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Roboto+Mono:wght@400;500;600&family=Lora:wght@400;500;600&display=swap');
|
||||
|
||||
/* Enable class-based dark mode for Tailwind v4 */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* Claymorphism Theme - Real colors from tweakcn */
|
||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||
--font-mono: Roboto Mono, monospace;
|
||||
--font-serif: Lora, serif;
|
||||
--radius: 1.25rem;
|
||||
|
||||
/* Shadows - Claymorphism specific */
|
||||
--shadow-color: hsl(240 4% 60%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 10px;
|
||||
--shadow-spread: 4px;
|
||||
--shadow-offset-x: 2px;
|
||||
--shadow-offset-y: 2px;
|
||||
/* Technical Trust Design System */
|
||||
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
--radius: 0.75rem;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
/* Light theme - Technical Trust palette */
|
||||
:root {
|
||||
--background: oklch(0.9232 0.0026 48.7171);
|
||||
--foreground: oklch(0.2795 0.0368 260.0310);
|
||||
--card: oklch(0.9699 0.0013 106.4238);
|
||||
--card-foreground: oklch(0.2795 0.0368 260.0310);
|
||||
--popover: oklch(0.9699 0.0013 106.4238);
|
||||
--popover-foreground: oklch(0.2795 0.0368 260.0310);
|
||||
--primary: oklch(0.5854 0.2041 277.1173);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.8687 0.0043 56.3660);
|
||||
--secondary-foreground: oklch(0.4461 0.0263 256.8018);
|
||||
--muted: oklch(0.9232 0.0026 48.7171);
|
||||
--muted-foreground: oklch(0.5510 0.0234 264.3637);
|
||||
--accent: oklch(0.9376 0.0260 321.9388);
|
||||
--accent-foreground: oklch(0.3729 0.0306 259.7328);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.8687 0.0043 56.3660);
|
||||
--input: oklch(0.8687 0.0043 56.3660);
|
||||
--ring: oklch(0.5854 0.2041 277.1173);
|
||||
--background: #f8fafc; /* slate-50 */
|
||||
--foreground: #0f172a; /* slate-900 */
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f172a; /* slate-900 */
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0f172a;
|
||||
--primary: #2563eb; /* blue-600 */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f1f5f9; /* slate-100 */
|
||||
--secondary-foreground: #475569; /* slate-600 */
|
||||
--muted: #f1f5f9; /* slate-100 */
|
||||
--muted-foreground: #64748b; /* slate-500 */
|
||||
--accent: #eff6ff; /* blue-50 */
|
||||
--accent-foreground: #1e40af; /* blue-800 */
|
||||
--destructive: #dc2626; /* red-600 */
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e2e8f0; /* slate-200 */
|
||||
--input: #e2e8f0; /* slate-200 */
|
||||
--ring: #2563eb; /* blue-600 */
|
||||
|
||||
--shadow-color: hsl(240 4% 60%);
|
||||
--shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
|
||||
--shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
|
||||
--shadow-sm: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-md: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 2px 4px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-lg: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 4px 6px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 8px 10px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45);
|
||||
/* Trust gradient colors */
|
||||
--trust-from: #0f172a; /* slate-900 */
|
||||
--trust-to: #1e293b; /* slate-800 */
|
||||
--verified-from: #10b981; /* emerald-500 */
|
||||
--verified-to: #059669; /* emerald-600 */
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
.dark {
|
||||
--background: oklch(0.2244 0.0074 67.4370);
|
||||
--foreground: oklch(0.9288 0.0126 255.5078);
|
||||
--card: oklch(0.3200 0.0080 59.3379);
|
||||
--card-foreground: oklch(0.9288 0.0126 255.5078);
|
||||
--popover: oklch(0.3200 0.0080 59.3379);
|
||||
--popover-foreground: oklch(0.9288 0.0126 255.5078);
|
||||
--primary: oklch(0.6801 0.1583 276.9349);
|
||||
--primary-foreground: oklch(0.2244 0.0074 67.4370);
|
||||
--secondary: oklch(0.3359 0.0077 59.4197);
|
||||
--secondary-foreground: oklch(0.8717 0.0093 258.3382);
|
||||
--muted: oklch(0.2287 0.0074 67.4469);
|
||||
--muted-foreground: oklch(0.7137 0.0192 261.3246);
|
||||
--accent: oklch(0.3896 0.0074 59.4734);
|
||||
--accent-foreground: oklch(0.8717 0.0093 258.3382);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--destructive-foreground: oklch(0.2244 0.0074 67.4370);
|
||||
--border: oklch(0.3359 0.0077 59.4197);
|
||||
--input: oklch(0.3359 0.0077 59.4197);
|
||||
--ring: oklch(0.6801 0.1583 276.9349);
|
||||
--background: #0f172a; /* slate-900 */
|
||||
--foreground: #f8fafc; /* slate-50 */
|
||||
--card: #1e293b; /* slate-800 */
|
||||
--card-foreground: #f8fafc;
|
||||
--popover: #1e293b;
|
||||
--popover-foreground: #f8fafc;
|
||||
--primary: #3b82f6; /* blue-500 */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #334155; /* slate-700 */
|
||||
--secondary-foreground: #e2e8f0;
|
||||
--muted: #1e293b; /* slate-800 */
|
||||
--muted-foreground: #94a3b8; /* slate-400 */
|
||||
--accent: #1e3a5f; /* blue-900/50 */
|
||||
--accent-foreground: #93c5fd; /* blue-300 */
|
||||
--destructive: #ef4444; /* red-500 */
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #334155; /* slate-700 */
|
||||
--input: #334155;
|
||||
--ring: #3b82f6;
|
||||
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45);
|
||||
--trust-from: #1e293b;
|
||||
--trust-to: #334155;
|
||||
--verified-from: #059669;
|
||||
--verified-to: #047857;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
@@ -92,95 +87,106 @@
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Dark mode text color overrides for Alert component */
|
||||
.dark .text-blue-900 {
|
||||
color: rgb(191 219 254) !important;
|
||||
/* ===========================================
|
||||
Technical Trust Custom Classes
|
||||
=========================================== */
|
||||
|
||||
/* Trust gradient - primary dark buttons and logo */
|
||||
.trust-gradient {
|
||||
background: linear-gradient(135deg, var(--trust-from) 0%, var(--trust-to) 100%);
|
||||
}
|
||||
|
||||
.dark .text-green-900 {
|
||||
color: rgb(187 247 208) !important;
|
||||
/* Verified gradient - success/confirmed states */
|
||||
.verified-gradient {
|
||||
background: linear-gradient(135deg, var(--verified-from) 0%, var(--verified-to) 100%);
|
||||
}
|
||||
|
||||
.dark .text-red-900 {
|
||||
color: rgb(254 202 202) !important;
|
||||
/* Accent border for technical blocks */
|
||||
.accent-border {
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.dark .text-yellow-900 {
|
||||
color: rgb(254 240 138) !important;
|
||||
/* Grid background for document viewer */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* Dark mode background color overrides for Alert component */
|
||||
.dark .bg-blue-50 {
|
||||
background-color: rgb(30 58 138 / 0.2) !important;
|
||||
.dark .grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.dark .bg-green-50 {
|
||||
background-color: rgb(20 83 45 / 0.2) !important;
|
||||
}
|
||||
/* ===========================================
|
||||
Card styles
|
||||
=========================================== */
|
||||
|
||||
.dark .bg-red-50 {
|
||||
background-color: rgb(127 29 29 / 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .bg-yellow-50 {
|
||||
background-color: rgb(113 63 18 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* Claymorphism custom classes */
|
||||
.clay-card {
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dark .clay-card {
|
||||
background: oklch(0.3200 0.0080 59.3379);
|
||||
border-color: oklch(0.3359 0.0077 59.4197);
|
||||
}
|
||||
|
||||
.clay-card-hover {
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.clay-card-hover:hover {
|
||||
background: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Button styles
|
||||
=========================================== */
|
||||
|
||||
.clay-button {
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.clay-button:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Input styles
|
||||
=========================================== */
|
||||
|
||||
.clay-input {
|
||||
background: var(--input);
|
||||
backdrop-filter: blur(5px);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.clay-input:focus {
|
||||
border-color: var(--ring);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 20%, transparent);
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
||||
}
|
||||
|
||||
/* Tailwind utilities using new theme variables */
|
||||
.dark .clay-input:focus {
|
||||
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.2);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Utility classes for theme variables
|
||||
=========================================== */
|
||||
|
||||
.bg-background {
|
||||
background-color: var(--background);
|
||||
}
|
||||
@@ -269,3 +275,93 @@ body {
|
||||
--tw-ring-offset-color: var(--background);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Animation
|
||||
=========================================== */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Dark mode overrides for status colors
|
||||
=========================================== */
|
||||
|
||||
/* Success/Emerald */
|
||||
.dark .bg-emerald-50 {
|
||||
background-color: rgb(6 78 59 / 0.2) !important;
|
||||
}
|
||||
.dark .text-emerald-600,
|
||||
.dark .text-emerald-700 {
|
||||
color: rgb(110 231 183) !important;
|
||||
}
|
||||
|
||||
/* Warning/Amber */
|
||||
.dark .bg-amber-50 {
|
||||
background-color: rgb(120 53 15 / 0.2) !important;
|
||||
}
|
||||
.dark .text-amber-600,
|
||||
.dark .text-amber-700 {
|
||||
color: rgb(252 211 77) !important;
|
||||
}
|
||||
|
||||
/* Danger/Red */
|
||||
.dark .bg-red-50 {
|
||||
background-color: rgb(127 29 29 / 0.2) !important;
|
||||
}
|
||||
.dark .text-red-600,
|
||||
.dark .text-red-700,
|
||||
.dark .text-red-900 {
|
||||
color: rgb(252 165 165) !important;
|
||||
}
|
||||
|
||||
/* Info/Blue */
|
||||
.dark .bg-blue-50 {
|
||||
background-color: rgb(30 58 138 / 0.2) !important;
|
||||
}
|
||||
.dark .text-blue-600,
|
||||
.dark .text-blue-700,
|
||||
.dark .text-blue-900 {
|
||||
color: rgb(147 197 253) !important;
|
||||
}
|
||||
|
||||
/* Green */
|
||||
.dark .bg-green-50 {
|
||||
background-color: rgb(20 83 45 / 0.2) !important;
|
||||
}
|
||||
.dark .text-green-600,
|
||||
.dark .text-green-700,
|
||||
.dark .text-green-800,
|
||||
.dark .text-green-900 {
|
||||
color: rgb(134 239 172) !important;
|
||||
}
|
||||
|
||||
/* Orange */
|
||||
.dark .bg-orange-50 {
|
||||
background-color: rgb(124 45 18 / 0.2) !important;
|
||||
}
|
||||
.dark .text-orange-600,
|
||||
.dark .text-orange-700,
|
||||
.dark .text-orange-800 {
|
||||
color: rgb(253 186 116) !important;
|
||||
}
|
||||
|
||||
/* Purple */
|
||||
.dark .bg-purple-50,
|
||||
.dark .bg-purple-500\/10 {
|
||||
background-color: rgb(88 28 135 / 0.2) !important;
|
||||
}
|
||||
.dark .text-purple-600 {
|
||||
color: rgb(192 132 252) !important;
|
||||
}
|
||||
|
||||