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)
This commit is contained in:
Benjamin
2025-12-23 11:31:16 +01:00
parent c374021675
commit e4521d87c7
38 changed files with 2150 additions and 1856 deletions

View File

@@ -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__';

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
webapp/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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

View 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
View 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

View 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": "/"
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">
&copy; {{ new Date().getFullYear() }} {{ t('footer.license') }}
</span>
</div>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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',
},
},

View File

@@ -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>

View File

@@ -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
)"
/>

View File

@@ -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
)"
/>

View File

@@ -518,6 +518,7 @@
"back": "Zurück",
"next": "Weiter",
"previous": "Zurück",
"skipToContent": "Zum Hauptinhalt springen"
"skipToContent": "Zum Hauptinhalt springen",
"refresh": "Aktualisieren"
}
}

View File

@@ -518,6 +518,7 @@
"back": "Back",
"next": "Next",
"previous": "Previous",
"skipToContent": "Skip to main content"
"skipToContent": "Skip to main content",
"refresh": "Refresh"
}
}

View File

@@ -518,6 +518,7 @@
"back": "Volver",
"next": "Siguiente",
"previous": "Anterior",
"skipToContent": "Ir al contenido principal"
"skipToContent": "Ir al contenido principal",
"refresh": "Actualizar"
}
}

View File

@@ -515,6 +515,7 @@
"back": "Retour",
"next": "Suivant",
"previous": "Précédent",
"skipToContent": "Aller au contenu principal"
"skipToContent": "Aller au contenu principal",
"refresh": "Actualiser"
}
}

View File

@@ -518,6 +518,7 @@
"back": "Indietro",
"next": "Successivo",
"previous": "Precedente",
"skipToContent": "Vai al contenuto principale"
"skipToContent": "Vai al contenuto principale",
"refresh": "Aggiorna"
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}