Initial Commit
This commit is contained in:
24
www/.gitignore
vendored
Normal file
24
www/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
www/README.md
Normal file
5
www/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
12
www/capacitor.config.ts
Normal file
12
www/capacitor.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli'
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.syncpad.app',
|
||||
appName: 'SyncPad',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
androidScheme: 'https',
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
25
www/components.json
Normal file
25
www/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "reka-nova",
|
||||
"font": "geist-sans",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/style.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
13
www/index.html
Normal file
13
www/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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>SyncPad</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
11387
www/package-lock.json
generated
Normal file
11387
www/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
www/package.json
Normal file
48
www/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "sync-pad",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node scripts/generate-icons.mjs && vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"cap:sync": "npx cap sync",
|
||||
"cap:open:android": "npx cap open android",
|
||||
"cap:open:ios": "npx cap open ios",
|
||||
"cap:add:android": "npx cap add android",
|
||||
"cap:add:ios": "npx cap add ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/cli": "^8.3.1",
|
||||
"@capacitor/core": "^8.3.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"marked": "^18.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"reka-ui": "^2.9.6",
|
||||
"shadcn-vue": "^2.6.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.10",
|
||||
"vue-tsc": "^3.2.7"
|
||||
}
|
||||
}
|
||||
1
www/public/favicon.svg
Normal file
1
www/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
www/public/icons.svg
Normal file
24
www/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
www/public/pwa-192x192-maskable.png
Normal file
BIN
www/public/pwa-192x192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
www/public/pwa-192x192.png
Normal file
BIN
www/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
www/public/pwa-512x512-maskable.png
Normal file
BIN
www/public/pwa-512x512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
www/public/pwa-512x512.png
Normal file
BIN
www/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
33
www/scripts/generate-icons.mjs
Normal file
33
www/scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import sharp from 'sharp'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
const svg = readFileSync('public/favicon.svg')
|
||||
|
||||
const sizes = [192, 512]
|
||||
|
||||
for (const size of sizes) {
|
||||
await sharp(svg)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(`public/pwa-${size}x${size}.png`)
|
||||
console.log(`Generated pwa-${size}x${size}.png`)
|
||||
}
|
||||
|
||||
const maskablePad = 0.2
|
||||
for (const size of sizes) {
|
||||
const padPx = Math.round(size * maskablePad)
|
||||
await sharp(svg)
|
||||
.resize(size - padPx * 2, size - padPx * 2)
|
||||
.extend({
|
||||
top: padPx,
|
||||
bottom: padPx,
|
||||
left: padPx,
|
||||
right: padPx,
|
||||
background: { r: 10, g: 10, b: 10, alpha: 1 },
|
||||
})
|
||||
.png()
|
||||
.toFile(`public/pwa-${size}x${size}-maskable.png`)
|
||||
console.log(`Generated pwa-${size}x${size}-maskable.png`)
|
||||
}
|
||||
|
||||
console.log('Done.')
|
||||
9
www/src/App.vue
Normal file
9
www/src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<router-view />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
199
www/src/components/auth/KeySetup.vue
Normal file
199
www/src/components/auth/KeySetup.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Key, Copy, Check, Shield, Sparkles, ArrowRight, QrCode, Scan } from 'lucide-vue-next'
|
||||
import QrCodeDisplay from '@/components/auth/QrCodeDisplay.vue'
|
||||
import QrCodeScanner from '@/components/auth/QrCodeScanner.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'setup-complete'): void
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const activeTab = ref('generate')
|
||||
const generatedSecretKey = ref('')
|
||||
const importedSecretKey = ref('')
|
||||
const importError = ref('')
|
||||
const copied = ref(false)
|
||||
const qrDisplayOpen = ref(false)
|
||||
const qrScanOpen = ref(false)
|
||||
|
||||
async function handleGenerate() {
|
||||
const pair = auth.generateKeyPair()
|
||||
generatedSecretKey.value = pair.secretKey
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
importError.value = ''
|
||||
const trimmed = importedSecretKey.value.trim()
|
||||
if (!trimmed) {
|
||||
importError.value = 'Please enter a secret key.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pair = auth.importKeyPair(trimmed)
|
||||
await auth.setupKeyPair(pair)
|
||||
emit('setup-complete')
|
||||
} catch (e) {
|
||||
console.error('Key import failed:', e)
|
||||
importError.value = 'Invalid secret key. Please check and try again.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
if (!generatedSecretKey.value) return
|
||||
try {
|
||||
const pair = auth.importKeyPair(generatedSecretKey.value)
|
||||
await auth.setupKeyPair(pair)
|
||||
emit('setup-complete')
|
||||
} catch (e) {
|
||||
console.error('Key setup failed:', e)
|
||||
importError.value = 'Failed to set up keys.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQrScan(value: string) {
|
||||
importedSecretKey.value = value
|
||||
activeTab.value = 'import'
|
||||
}
|
||||
|
||||
async function copySecretKey() {
|
||||
if (!generatedSecretKey.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedSecretKey.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = generatedSecretKey.value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full max-w-lg mx-auto">
|
||||
<CardHeader class="text-center">
|
||||
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Shield class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle class="text-2xl">Set Up SyncPad</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a new encryption key pair or import an existing one.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="generate">
|
||||
<Sparkles class="mr-2 h-4 w-4" />
|
||||
Generate New Key
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import">
|
||||
<Key class="mr-2 h-4 w-4" />
|
||||
Import Existing Key
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="generate" class="mt-4 space-y-4">
|
||||
<div v-if="!generatedSecretKey" class="flex flex-col items-center gap-3 py-6">
|
||||
<Shield class="h-16 w-16 text-muted-foreground/40" />
|
||||
<p class="text-sm text-muted-foreground text-center max-w-xs">
|
||||
Generate a new Ed25519 key pair for end-to-end encryption. Your secret key is your
|
||||
only way to access your notes.
|
||||
</p>
|
||||
<Button @click="handleGenerate" class="mt-2">
|
||||
<Sparkles class="mr-2 h-4 w-4" />
|
||||
Generate Key Pair
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<p class="text-sm font-medium text-amber-600 dark:text-amber-400 mb-2">
|
||||
Save your secret key! It cannot be recovered.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Store it somewhere safe. You will need it to access your notes from other devices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Your Secret Key</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
:model-value="generatedSecretKey"
|
||||
readonly
|
||||
class="font-mono text-xs"
|
||||
/>
|
||||
<Button variant="outline" size="icon" @click="copySecretKey">
|
||||
<Check v-if="copied" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button @click="handleContinue" class="flex-1">
|
||||
Continue to SyncPad
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" @click="qrDisplayOpen = true">
|
||||
<QrCode class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="importError" class="text-sm text-destructive text-center">{{ importError }}</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="import" class="mt-4 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="import-key">Secret Key (Base64)</Label>
|
||||
<Textarea
|
||||
id="import-key"
|
||||
v-model="importedSecretKey"
|
||||
placeholder="Paste your secret key here..."
|
||||
class="font-mono text-xs min-h-[100px]"
|
||||
/>
|
||||
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button @click="handleImport" class="flex-1" :disabled="!importedSecretKey.trim()">
|
||||
<Key class="mr-2 h-4 w-4" />
|
||||
Import Key
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" @click="qrScanOpen = true">
|
||||
<Scan class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<QrCodeDisplay
|
||||
v-model:open="qrDisplayOpen"
|
||||
:value="generatedSecretKey"
|
||||
/>
|
||||
|
||||
<QrCodeScanner
|
||||
v-model:open="qrScanOpen"
|
||||
@scan-success="handleQrScan"
|
||||
/>
|
||||
</template>
|
||||
59
www/src/components/auth/QrCodeDisplay.vue
Normal file
59
www/src/components/auth/QrCodeDisplay.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
value: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const qrSvg = ref('')
|
||||
|
||||
watch(() => props.value, async (val) => {
|
||||
if (val) {
|
||||
qrSvg.value = await QRCode.toString(val, {
|
||||
type: 'svg',
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: { dark: '#000', light: '#fff' },
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Secret Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan this QR code on another device to import your key.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex justify-center py-4">
|
||||
<div
|
||||
v-if="qrSvg"
|
||||
class="bg-white p-4 rounded-xl"
|
||||
v-html="qrSvg"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" @click="emit('update:open', false)">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
139
www/src/components/auth/QrCodeScanner.vue
Normal file
139
www/src/components/auth/QrCodeScanner.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, nextTick } from 'vue'
|
||||
import jsQR from 'jsqr'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Camera, AlertCircle } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'scan-success', value: string): void
|
||||
}>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const error = ref('')
|
||||
const scanning = ref(false)
|
||||
let stream: MediaStream | null = null
|
||||
let animationId: number | null = null
|
||||
|
||||
async function startCamera() {
|
||||
error.value = ''
|
||||
scanning.value = true
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
})
|
||||
await nextTick()
|
||||
if (videoEl.value) {
|
||||
videoEl.value.srcObject = stream
|
||||
await videoEl.value.play()
|
||||
}
|
||||
scanFrame()
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Could not access camera.'
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scanFrame() {
|
||||
if (!videoEl.value || !canvasEl.value) return
|
||||
const video = videoEl.value
|
||||
const canvas = canvasEl.value
|
||||
if (video.readyState !== video.HAVE_ENOUGH_DATA) {
|
||||
animationId = requestAnimationFrame(scanFrame)
|
||||
return
|
||||
}
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, canvas.width, canvas.height)
|
||||
if (code) {
|
||||
stopCamera()
|
||||
emit('scan-success', code.data)
|
||||
emit('update:open', false)
|
||||
return
|
||||
}
|
||||
animationId = requestAnimationFrame(scanFrame)
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
animationId = null
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
}
|
||||
scanning.value = false
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
stopCamera()
|
||||
} else {
|
||||
startCamera()
|
||||
}
|
||||
emit('update:open', open)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCamera()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="onOpenChange">
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scan QR Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Point your camera at a SyncPad secret key QR code.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="relative w-full aspect-square rounded-lg overflow-hidden bg-black">
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
autoplay
|
||||
playsinline
|
||||
/>
|
||||
<canvas ref="canvasEl" class="hidden" />
|
||||
<div
|
||||
v-if="!scanning && !error"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Camera class="h-10 w-10 text-white/40" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="error"
|
||||
class="flex items-center gap-2 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" @click="onOpenChange(false)">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
208
www/src/components/layout/AppSidebar.vue
Normal file
208
www/src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import type { DecryptedNote, Identity, Contact } from '@/types'
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import NoteList from '@/components/note/NoteList.vue'
|
||||
import ShareDialog from '@/components/note/ShareDialog.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Plus, Settings, Shield, Key, Copy, Check, Trash2, LogOut,
|
||||
User, Users, Share2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
notes: DecryptedNote[]
|
||||
selectedNoteId: string | null
|
||||
identities: Identity[]
|
||||
activeIdentityId: string
|
||||
contacts: Contact[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-note', id: string): void
|
||||
(e: 'create-note'): void
|
||||
(e: 'delete-note', id: string): void
|
||||
(e: 'switch-identity', id: string): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const settingsOpen = ref(false)
|
||||
const copied = ref(false)
|
||||
const confirmReset = ref(false)
|
||||
const shareDialogOpen = ref(false)
|
||||
|
||||
async function copyKey() {
|
||||
const key = auth.primaryIdentity.value?.keyPair.secretKey
|
||||
if (!key) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = key
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!confirmReset.value) {
|
||||
confirmReset.value = true
|
||||
return
|
||||
}
|
||||
auth.clearKeys()
|
||||
settingsOpen.value = false
|
||||
confirmReset.value = false
|
||||
router.push('/setup')
|
||||
}
|
||||
|
||||
async function handleShareComplete() {
|
||||
shareDialogOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="w-64 h-full flex flex-col border-r bg-card shrink-0">
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-primary">
|
||||
<Shield class="h-3.5 w-3.5 text-primary-foreground" />
|
||||
</div>
|
||||
<span class="font-semibold text-sm">SyncPad</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 pb-1 space-y-0.5">
|
||||
<button
|
||||
@click="emit('switch-identity', '__all__')"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs transition-colors"
|
||||
:class="activeIdentityId === '__all__'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'"
|
||||
>
|
||||
<Users class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>All Notes</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="identity in identities"
|
||||
:key="identity.id"
|
||||
@click="emit('switch-identity', identity.id)"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs transition-colors"
|
||||
:class="identity.id === activeIdentityId
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'"
|
||||
>
|
||||
<User v-if="identity.isPrimary" class="h-3.5 w-3.5 shrink-0" />
|
||||
<Users v-else class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ identity.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="px-3 py-2 space-y-1.5">
|
||||
<Button class="w-full justify-start" size="sm" @click="emit('create-note')">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
New Note
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start text-muted-foreground"
|
||||
@click="shareDialogOpen = true"
|
||||
>
|
||||
<Share2 class="mr-2 h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<NoteList
|
||||
:notes="notes"
|
||||
:selected-note-id="selectedNoteId"
|
||||
@select-note="emit('select-note', $event)"
|
||||
@delete-note="emit('delete-note', $event)"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="p-2">
|
||||
<Dialog v-model:open="settingsOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="w-full justify-start text-muted-foreground">
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Key Information</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your Ed25519 key pair for authentication and encryption.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Public Key</Label>
|
||||
<Input :model-value="auth.primaryIdentity.value?.keyPair.publicKey ?? ''" readonly class="font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Secret Key (keep this safe!)</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input :model-value="auth.primaryIdentity.value?.keyPair.secretKey ?? ''" readonly class="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon" @click="copyKey">
|
||||
<Check v-if="copied" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex-col gap-2 sm:flex-col">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="handleReset"
|
||||
>
|
||||
<LogOut v-if="!confirmReset" class="mr-2 h-4 w-4" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
{{ confirmReset ? 'Confirm Reset' : 'Reset All Data' }}
|
||||
</Button>
|
||||
<p v-if="confirmReset" class="text-xs text-destructive text-center">
|
||||
This will remove your keys and redirect you to setup.
|
||||
</p>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<ShareDialog
|
||||
v-model:open="shareDialogOpen"
|
||||
:identities="identities"
|
||||
@share-complete="handleShareComplete"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
105
www/src/components/note/MdHelpDialog.vue
Normal file
105
www/src/components/note/MdHelpDialog.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Headings',
|
||||
items: [
|
||||
{ syntax: '# H1', result: 'Largest heading' },
|
||||
{ syntax: '## H2', result: 'Second largest' },
|
||||
{ syntax: '### H3', result: 'Third largest' },
|
||||
{ syntax: '#### H4', result: 'Fourth' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Text Formatting',
|
||||
items: [
|
||||
{ syntax: '**bold**', result: 'Bold text' },
|
||||
{ syntax: '*italic*', result: 'Italic text' },
|
||||
{ syntax: '~~strikethrough~~', result: 'Strikethrough' },
|
||||
{ syntax: '`code`', result: 'Inline code' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Lists',
|
||||
items: [
|
||||
{ syntax: '- item\n- item\n- item', result: 'Unordered list' },
|
||||
{ syntax: '1. First\n2. Second\n3. Third', result: 'Ordered list' },
|
||||
{ syntax: '- [x] Done\n- [ ] Todo', result: 'Task list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Links & Images',
|
||||
items: [
|
||||
{ syntax: '[text](url)', result: 'Hyperlink' },
|
||||
{ syntax: '', result: 'Image' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Blocks',
|
||||
items: [
|
||||
{ syntax: '> quoted text', result: 'Blockquote' },
|
||||
{ syntax: '```\ncode block\n```', result: 'Fenced code block' },
|
||||
{ syntax: '```js\nconst x = 1\n```', result: 'Code with language' },
|
||||
{ syntax: '---', result: 'Horizontal rule' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tables',
|
||||
items: [
|
||||
{ syntax: '| A | B |\n| --- | --- |\n| 1 | 2 |', result: 'Table' },
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-lg max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Markdown Reference</DialogTitle>
|
||||
<DialogDescription>
|
||||
Quick syntax guide for formatting your notes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea class="max-h-[60vh] pr-4">
|
||||
<div class="space-y-6">
|
||||
<div v-for="section in sections" :key="section.title">
|
||||
<h4 class="text-sm font-semibold mb-2">{{ section.title }}</h4>
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="item in section.items"
|
||||
:key="item.syntax"
|
||||
class="flex items-start gap-4 text-xs p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<code class="font-mono text-primary shrink-0 whitespace-pre min-w-[120px] leading-relaxed">{{ item.syntax }}</code>
|
||||
<span class="text-muted-foreground leading-relaxed pt-px">{{ item.result }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button variant="outline" @click="emit('update:open', false)">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
81
www/src/components/note/NoteEditor.vue
Normal file
81
www/src/components/note/NoteEditor.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import type { DecryptedNote } from '@/types'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
const props = defineProps<{
|
||||
note: DecryptedNote | null
|
||||
isLoading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', content: string): void
|
||||
(e: 'content-change', content: string): void
|
||||
(e: 'update-title', title: string): void
|
||||
}>()
|
||||
|
||||
const content = ref(props.note?.content ?? '')
|
||||
const lastSaved = ref<string | null>(null)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(() => props.note, (newNote) => {
|
||||
if (newNote) {
|
||||
content.value = newNote.content
|
||||
lastSaved.value = null
|
||||
}
|
||||
})
|
||||
|
||||
function onInput() {
|
||||
if (previewTimer) clearTimeout(previewTimer)
|
||||
previewTimer = setTimeout(() => {
|
||||
emit('content-change', content.value)
|
||||
}, 50)
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (props.note && content.value !== props.note.content) {
|
||||
emit('update', content.value)
|
||||
lastSaved.value = new Date().toLocaleTimeString()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (previewTimer) clearTimeout(previewTimer)
|
||||
emit('content-change', content.value)
|
||||
if (props.note && content.value !== props.note.content) {
|
||||
emit('update', content.value)
|
||||
lastSaved.value = new Date().toLocaleTimeString()
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col overflow-hidden relative">
|
||||
<div v-if="!note" class="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<p class="text-sm">Select a note to start editing</p>
|
||||
</div>
|
||||
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
||||
<Textarea
|
||||
v-model="content"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
class="flex-1 border-0 rounded-none resize-none focus-visible:ring-0 editor-textarea bg-transparent p-5 leading-relaxed"
|
||||
:placeholder="'Start writing...'"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<div
|
||||
v-if="lastSaved"
|
||||
class="absolute bottom-3 right-3 text-xs text-muted-foreground bg-background/80 px-2 py-0.5 rounded"
|
||||
>
|
||||
Saved {{ lastSaved }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
107
www/src/components/note/NoteList.vue
Normal file
107
www/src/components/note/NoteList.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import type { DecryptedNote } from '@/types'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { FileText, Trash2, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
notes: DecryptedNote[]
|
||||
selectedNoteId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-note', id: string): void
|
||||
(e: 'delete-note', id: string): void
|
||||
}>()
|
||||
|
||||
const contextMenuNoteId = ref<string | null>(null)
|
||||
const contextMenuPos = ref({ x: 0, y: 0 })
|
||||
|
||||
function handleContextMenu(event: MouseEvent, noteId: string) {
|
||||
event.preventDefault()
|
||||
contextMenuNoteId.value = noteId
|
||||
contextMenuPos.value = { x: event.clientX, y: event.clientY }
|
||||
document.addEventListener('click', closeContextMenu, { once: true })
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuNoteId.value = null
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'Just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'Yesterday'
|
||||
if (days < 7) return `${days}d ago`
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollArea class="flex-1">
|
||||
<div class="space-y-0.5 p-2">
|
||||
<div
|
||||
v-if="notes.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4 text-center"
|
||||
>
|
||||
<FileText class="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No notes yet.
|
||||
<br />
|
||||
Create your first note!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
@click="emit('select-note', note.id)"
|
||||
@contextmenu="handleContextMenu($event, note.id)"
|
||||
class="w-full text-left px-3 py-2.5 rounded-lg transition-colors group"
|
||||
:class="[
|
||||
note.id === selectedNoteId
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'hover:bg-muted/60 text-foreground'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate flex-1">
|
||||
{{ note.title || 'Untitled' }}
|
||||
</span>
|
||||
<button
|
||||
@click.stop="emit('delete-note', note.id)"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Badge variant="secondary" class="mt-1 text-[10px] px-1.5 py-0">
|
||||
{{ formatDate(note.updated_at) }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="contextMenuNoteId"
|
||||
class="fixed z-50 min-w-[140px] rounded-lg border bg-popover p-1 shadow-md animate-fade-in"
|
||||
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||
>
|
||||
<button
|
||||
@click="emit('delete-note', contextMenuNoteId); closeContextMenu()"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
29
www/src/components/note/NotePreview.vue
Normal file
29
www/src/components/note/NotePreview.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const html = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return marked.parse(props.content) as string
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollArea class="flex-1">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div v-if="!content" class="flex items-center justify-center h-full text-muted-foreground p-8">
|
||||
<p class="text-sm">Preview will appear here</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="markdown-body p-8 max-w-3xl mx-auto"
|
||||
v-html="html"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</template>
|
||||
307
www/src/components/note/ShareDialog.vue
Normal file
307
www/src/components/note/ShareDialog.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Identity } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { encodePublicKeyLink, decodePublicKeyLink } from '@/lib/sharing'
|
||||
import { Copy, Check, QrCode, UserPlus, Users, Link, Pencil, X } from 'lucide-vue-next'
|
||||
import QrCodeScanner from '@/components/auth/QrCodeScanner.vue'
|
||||
import QrCodeDisplay from '@/components/auth/QrCodeDisplay.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
identities: Identity[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'share-complete'): void
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const notesStore = useNotesStore()
|
||||
const myPublicKey = ref(auth.primaryIdentity.value?.keyPair.publicKey ?? '')
|
||||
const contactKey = ref('')
|
||||
const contactLabel = ref('')
|
||||
const addContactError = ref('')
|
||||
const copiedLink = ref(false)
|
||||
const copiedKey = ref(false)
|
||||
const qrDisplayOpen = ref(false)
|
||||
const qrScanOpen = ref(false)
|
||||
const editingContact = ref<string | null>(null)
|
||||
const editLabel = ref('')
|
||||
|
||||
const inviteLink = ref('')
|
||||
|
||||
function updateInviteLink() {
|
||||
if (myPublicKey.value) {
|
||||
inviteLink.value = encodePublicKeyLink(myPublicKey.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => auth.primaryIdentity.value?.keyPair.publicKey, (pk) => {
|
||||
if (pk) {
|
||||
myPublicKey.value = pk
|
||||
updateInviteLink()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function copyInviteLink() {
|
||||
if (!inviteLink.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink.value)
|
||||
copiedLink.value = true
|
||||
setTimeout(() => { copiedLink.value = false }, 2000)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = inviteLink.value
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copiedLink.value = true
|
||||
setTimeout(() => { copiedLink.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMyPublicKey() {
|
||||
if (!myPublicKey.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(myPublicKey.value)
|
||||
copiedKey.value = true
|
||||
setTimeout(() => { copiedKey.value = false }, 2000)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = myPublicKey.value
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copiedKey.value = true
|
||||
setTimeout(() => { copiedKey.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddContact() {
|
||||
addContactError.value = ''
|
||||
const key = contactKey.value.trim()
|
||||
if (!key) {
|
||||
addContactError.value = 'Please enter a public key or invite link.'
|
||||
return
|
||||
}
|
||||
|
||||
const decoded = decodePublicKeyLink(key)
|
||||
const publicKey = decoded ?? key
|
||||
|
||||
if (publicKey.length < 32) {
|
||||
addContactError.value = 'Invalid public key format.'
|
||||
return
|
||||
}
|
||||
|
||||
const label = contactLabel.value.trim() || 'Contact'
|
||||
|
||||
try {
|
||||
await auth.addContact(publicKey, label)
|
||||
|
||||
const primary = auth.primaryIdentity.value
|
||||
if (primary?.keyPair) {
|
||||
notesStore.api.sendInvitation(
|
||||
primary.keyPair,
|
||||
publicKey,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
label
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
contactKey.value = ''
|
||||
contactLabel.value = ''
|
||||
emit('share-complete')
|
||||
} catch {
|
||||
addContactError.value = 'Failed to add contact. Check the public key.'
|
||||
}
|
||||
}
|
||||
|
||||
function handleQrScan(value: string) {
|
||||
contactKey.value = value
|
||||
}
|
||||
|
||||
function startRename(pubKey: string, currentLabel: string) {
|
||||
editingContact.value = pubKey
|
||||
editLabel.value = currentLabel
|
||||
}
|
||||
|
||||
function finishRename(pubKey: string) {
|
||||
const label = editLabel.value.trim()
|
||||
if (label) {
|
||||
auth.renameContact(pubKey, label)
|
||||
}
|
||||
editingContact.value = null
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingContact.value = null
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
addContactError.value = ''
|
||||
}
|
||||
emit('update:open', open)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="onOpenChange">
|
||||
<DialogContent class="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share & Invite</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share your public key to let others share notes with you, or add a contact.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="space-y-3">
|
||||
<Label class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your Invite</Label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
:model-value="inviteLink"
|
||||
readonly
|
||||
class="font-mono text-xs flex-1 min-w-0"
|
||||
placeholder="No key yet..."
|
||||
/>
|
||||
<Button variant="outline" size="icon" @click="copyInviteLink" :disabled="!inviteLink">
|
||||
<Check v-if="copiedLink" class="h-4 w-4 text-green-500" />
|
||||
<Link v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" class="flex-1" @click="copyMyPublicKey" :disabled="!myPublicKey">
|
||||
<Copy class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ copiedKey ? 'Copied!' : 'Copy Public Key' }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="flex-1" @click="qrDisplayOpen = true" :disabled="!myPublicKey">
|
||||
<QrCode class="mr-1.5 h-3.5 w-3.5" />
|
||||
Show QR
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 space-y-3">
|
||||
<Label class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Add Contact</Label>
|
||||
<div class="space-y-2">
|
||||
<Label for="contact-label" class="text-xs">Display Name</Label>
|
||||
<Input
|
||||
id="contact-label"
|
||||
v-model="contactLabel"
|
||||
placeholder="e.g. Alice"
|
||||
class="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="contact-key" class="text-xs">Public Key or Invite Link</Label>
|
||||
<div class="flex gap-2">
|
||||
<Textarea
|
||||
id="contact-key"
|
||||
v-model="contactKey"
|
||||
placeholder="Paste public key or syncpad:// link..."
|
||||
class="font-mono text-xs min-h-[60px] flex-1 min-w-0"
|
||||
/>
|
||||
<Button variant="outline" size="icon" @click="qrScanOpen = true" class="shrink-0 self-start">
|
||||
<QrCode class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="addContactError" class="text-xs text-destructive">{{ addContactError }}</p>
|
||||
</div>
|
||||
<Button
|
||||
class="w-full"
|
||||
@click="handleAddContact"
|
||||
:disabled="!contactKey.trim()"
|
||||
>
|
||||
<UserPlus class="mr-2 h-4 w-4" />
|
||||
Add Contact
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.contacts.value.length > 0" class="border-t pt-3 space-y-2">
|
||||
<Label class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Contacts ({{ auth.contacts.value.length }})
|
||||
</Label>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="contact in auth.contacts.value"
|
||||
:key="contact.publicKey"
|
||||
class="flex items-center justify-between text-xs px-2 py-1.5 rounded-md bg-muted/50 group"
|
||||
>
|
||||
<template v-if="editingContact === contact.publicKey">
|
||||
<div class="flex items-center gap-1 flex-1 min-w-0">
|
||||
<Users class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
v-model="editLabel"
|
||||
class="h-6 text-xs flex-1 min-w-0"
|
||||
@keyup.enter="finishRename(contact.publicKey)"
|
||||
@keyup.escape="cancelRename()"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-0.5 shrink-0 ml-1">
|
||||
<button @click="finishRename(contact.publicKey)" class="p-0.5 text-green-500 hover:bg-green-500/10 rounded">
|
||||
<Check class="h-3 w-3" />
|
||||
</button>
|
||||
<button @click="cancelRename()" class="p-0.5 text-muted-foreground hover:text-destructive rounded">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Users class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<span class="truncate">{{ contact.label }}</span>
|
||||
</div>
|
||||
<div class="flex gap-0.5 shrink-0 ml-1">
|
||||
<button
|
||||
@click="startRename(contact.publicKey, contact.label)"
|
||||
class="p-0.5 text-muted-foreground hover:text-foreground rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
@click="auth.removeContact(contact.publicKey)"
|
||||
class="text-xs text-muted-foreground hover:text-destructive shrink-0"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<QrCodeDisplay
|
||||
v-model:open="qrDisplayOpen"
|
||||
:value="inviteLink"
|
||||
/>
|
||||
|
||||
<QrCodeScanner
|
||||
v-model:open="qrScanOpen"
|
||||
@scan-success="handleQrScan"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
27
www/src/components/ui/badge/Badge.vue
Normal file
27
www/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { BadgeVariants } from '.'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { badgeVariants } from '.'
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
24
www/src/components/ui/badge/index.ts
Normal file
24
www/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
31
www/src/components/ui/button/Button.vue
Normal file
31
www/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
35
www/src/components/ui/button/index.ts
Normal file
35
www/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'icon': 'size-8',
|
||||
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
|
||||
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
21
www/src/components/ui/card/Card.vue
Normal file
21
www/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
size?: 'default' | 'sm'
|
||||
}>(), {
|
||||
size: 'default',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:data-size="size"
|
||||
:class="cn('ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
www/src/components/ui/card/CardAction.vue
Normal file
17
www/src/components/ui/card/CardAction.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
www/src/components/ui/card/CardContent.vue
Normal file
17
www/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-4 group-data-[size=sm]/card:px-3', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
www/src/components/ui/card/CardDescription.vue
Normal file
17
www/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
www/src/components/ui/card/CardFooter.vue
Normal file
17
www/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
www/src/components/ui/card/CardHeader.vue
Normal file
17
www/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
www/src/components/ui/card/CardTitle.vue
Normal file
17
www/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-title"
|
||||
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
www/src/components/ui/card/index.ts
Normal file
7
www/src/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as CardAction } from './CardAction.vue'
|
||||
export { default as CardContent } from './CardContent.vue'
|
||||
export { default as CardDescription } from './CardDescription.vue'
|
||||
export { default as CardFooter } from './CardFooter.vue'
|
||||
export { default as CardHeader } from './CardHeader.vue'
|
||||
export { default as CardTitle } from './CardTitle.vue'
|
||||
19
www/src/components/ui/dialog/Dialog.vue
Normal file
19
www/src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
www/src/components/ui/dialog/DialogClose.vue
Normal file
15
www/src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
53
www/src/components/ui/dialog/DialogContent.vue
Normal file
53
www/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DialogOverlay from './DialogOverlay.vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
as-child
|
||||
>
|
||||
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
www/src/components/ui/dialog/DialogDescription.vue
Normal file
23
www/src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
27
www/src/components/ui/dialog/DialogFooter.vue
Normal file
27
www/src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
showCloseButton: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
17
www/src/components/ui/dialog/DialogHeader.vue
Normal file
17
www/src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('gap-2 flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
www/src/components/ui/dialog/DialogOverlay.vue
Normal file
21
www/src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
60
www/src/components/ui/dialog/DialogScrollContent.vue
Normal file
60
www/src/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
www/src/components/ui/dialog/DialogTitle.vue
Normal file
23
www/src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
www/src/components/ui/dialog/DialogTrigger.vue
Normal file
15
www/src/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
10
www/src/components/ui/dialog/index.ts
Normal file
10
www/src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogOverlay } from './DialogOverlay.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
31
www/src/components/ui/input/Input.vue
Normal file
31
www/src/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
</template>
|
||||
1
www/src/components/ui/input/index.ts
Normal file
1
www/src/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
26
www/src/components/ui/label/Label.vue
Normal file
26
www/src/components/ui/label/Label.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { LabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Label } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
1
www/src/components/ui/label/index.ts
Normal file
1
www/src/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from './Label.vue'
|
||||
33
www/src/components/ui/scroll-area/ScrollArea.vue
Normal file
33
www/src/components/ui/scroll-area/ScrollArea.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaViewport,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ScrollBar from './ScrollBar.vue'
|
||||
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot
|
||||
data-slot="scroll-area"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('relative', props.class)"
|
||||
>
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
27
www/src/components/ui/scroll-area/ScrollBar.vue
Normal file
27
www/src/components/ui/scroll-area/ScrollBar.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaScrollbarProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
orientation: 'vertical',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
:data-orientation="orientation"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none', props.class)"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="rounded-full relative flex-1 bg-border"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
2
www/src/components/ui/scroll-area/index.ts
Normal file
2
www/src/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
export { default as ScrollBar } from './ScrollBar.vue'
|
||||
29
www/src/components/ui/separator/Separator.vue
Normal file
29
www/src/components/ui/separator/Separator.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Separator } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<
|
||||
SeparatorProps & { class?: HTMLAttributes['class'] }
|
||||
>(), {
|
||||
orientation: 'horizontal',
|
||||
decorative: true,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
1
www/src/components/ui/separator/index.ts
Normal file
1
www/src/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
||||
19
www/src/components/ui/sheet/Sheet.vue
Normal file
19
www/src/components/ui/sheet/Sheet.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="sheet"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
www/src/components/ui/sheet/SheetClose.vue
Normal file
15
www/src/components/ui/sheet/SheetClose.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="sheet-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
61
www/src/components/ui/sheet/SheetContent.vue
Normal file
61
www/src/components/ui/sheet/SheetContent.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SheetOverlay from './SheetOverlay.vue'
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes['class']
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||
side: 'right',
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'side', 'showCloseButton')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:data-side="side"
|
||||
:class="cn('bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10', props.class)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="sheet-close"
|
||||
as-child
|
||||
>
|
||||
<Button variant="ghost" class="absolute top-3 right-3" size="icon-sm">
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
21
www/src/components/ui/sheet/SheetDescription.vue
Normal file
21
www/src/components/ui/sheet/SheetDescription.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="sheet-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
15
www/src/components/ui/sheet/SheetFooter.vue
Normal file
15
www/src/components/ui/sheet/SheetFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
:class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
15
www/src/components/ui/sheet/SheetHeader.vue
Normal file
15
www/src/components/ui/sheet/SheetHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
:class="cn('gap-0.5 p-4 flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
www/src/components/ui/sheet/SheetOverlay.vue
Normal file
21
www/src/components/ui/sheet/SheetOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="sheet-overlay"
|
||||
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
21
www/src/components/ui/sheet/SheetTitle.vue
Normal file
21
www/src/components/ui/sheet/SheetTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="sheet-title"
|
||||
:class="cn('text-foreground text-base font-medium cn-font-heading', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
www/src/components/ui/sheet/SheetTrigger.vue
Normal file
15
www/src/components/ui/sheet/SheetTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="sheet-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
8
www/src/components/ui/sheet/index.ts
Normal file
8
www/src/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as Sheet } from './Sheet.vue'
|
||||
export { default as SheetClose } from './SheetClose.vue'
|
||||
export { default as SheetContent } from './SheetContent.vue'
|
||||
export { default as SheetDescription } from './SheetDescription.vue'
|
||||
export { default as SheetFooter } from './SheetFooter.vue'
|
||||
export { default as SheetHeader } from './SheetHeader.vue'
|
||||
export { default as SheetTitle } from './SheetTitle.vue'
|
||||
export { default as SheetTrigger } from './SheetTrigger.vue'
|
||||
25
www/src/components/ui/tabs/Tabs.vue
Normal file
25
www/src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="tabs"
|
||||
:data-orientation="forwarded.orientation || 'horizontal'"
|
||||
v-bind="forwarded"
|
||||
:class="cn('gap-2 group/tabs flex data-horizontal:flex-col', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
21
www/src/components/ui/tabs/TabsContent.vue
Normal file
21
www/src/components/ui/tabs/TabsContent.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsContent } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent
|
||||
data-slot="tabs-content"
|
||||
:class="cn('text-sm flex-1 outline-none', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
||||
29
www/src/components/ui/tabs/TabsList.vue
Normal file
29
www/src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { TabsListVariants } from '.'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsList } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { tabsListVariants } from '.'
|
||||
|
||||
const props = withDefaults(defineProps<TabsListProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: TabsListVariants['variant']
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'variant')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
data-slot="tabs-list"
|
||||
:data-variant="variant"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(tabsListVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
29
www/src/components/ui/tabs/TabsTrigger.vue
Normal file
29
www/src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
data-slot="tabs-trigger"
|
||||
:class="cn(
|
||||
'gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*=size-])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
|
||||
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
|
||||
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
24
www/src/components/ui/tabs/index.ts
Normal file
24
www/src/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TabsContent } from './TabsContent.vue'
|
||||
export { default as TabsList } from './TabsList.vue'
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||
|
||||
export const tabsListVariants = cva(
|
||||
'rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-muted',
|
||||
line: 'gap-1 bg-transparent',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type TabsListVariants = VariantProps<typeof tabsListVariants>
|
||||
28
www/src/components/ui/textarea/Textarea.vue
Normal file
28
www/src/components/ui/textarea/Textarea.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-model="modelValue"
|
||||
data-slot="textarea"
|
||||
:class="cn('border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||
/>
|
||||
</template>
|
||||
1
www/src/components/ui/textarea/index.ts
Normal file
1
www/src/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Textarea } from './Textarea.vue'
|
||||
19
www/src/components/ui/tooltip/Tooltip.vue
Normal file
19
www/src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
|
||||
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<TooltipRootProps>()
|
||||
const emits = defineEmits<TooltipRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="tooltip"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
34
www/src/components/ui/tooltip/TooltipContent.vue
Normal file
34
www/src/components/ui/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
sideOffset: 0,
|
||||
})
|
||||
|
||||
const emits = defineEmits<TooltipContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
data-slot="tooltip-content"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
14
www/src/components/ui/tooltip/TooltipProvider.vue
Normal file
14
www/src/components/ui/tooltip/TooltipProvider.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipProviderProps } from 'reka-ui'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
|
||||
const props = withDefaults(defineProps<TooltipProviderProps>(), {
|
||||
delayDuration: 0,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider v-bind="props">
|
||||
<slot />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
15
www/src/components/ui/tooltip/TooltipTrigger.vue
Normal file
15
www/src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipTriggerProps } from 'reka-ui'
|
||||
import { TooltipTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<TooltipTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipTrigger
|
||||
data-slot="tooltip-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
</template>
|
||||
4
www/src/components/ui/tooltip/index.ts
Normal file
4
www/src/components/ui/tooltip/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Tooltip } from './Tooltip.vue'
|
||||
export { default as TooltipContent } from './TooltipContent.vue'
|
||||
export { default as TooltipProvider } from './TooltipProvider.vue'
|
||||
export { default as TooltipTrigger } from './TooltipTrigger.vue'
|
||||
179
www/src/lib/api.ts
Normal file
179
www/src/lib/api.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { signRequest } from './signature'
|
||||
import type { Note, KeyPair, ShareInvitation } from '@/types'
|
||||
|
||||
interface CreatePayload {
|
||||
id: string
|
||||
encrypted_title: string
|
||||
encrypted_content: string
|
||||
iv: string
|
||||
}
|
||||
|
||||
interface UpdatePayload {
|
||||
encrypted_title: string
|
||||
encrypted_content: string
|
||||
iv: string
|
||||
}
|
||||
|
||||
function getAuthHeaders(keyPair: KeyPair, timestamp: number, body: string) {
|
||||
const nonce = crypto.randomUUID()
|
||||
const signature = signRequest(keyPair.secretKey, timestamp, body)
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-publickey': keyPair.publicKey,
|
||||
'x-signature': signature,
|
||||
'x-nonce': nonce,
|
||||
'x-timestamp': String(timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(baseUrl: string) {
|
||||
return {
|
||||
async listNotes(keyPair: KeyPair): Promise<Note[]> {
|
||||
const timestamp = Date.now()
|
||||
const body = ''
|
||||
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||
const res = await fetch(`${baseUrl}/api/notes`, { headers })
|
||||
if (!res.ok) throw new Error(`Failed to list notes: ${res.statusText}`)
|
||||
const data = await res.json()
|
||||
return data.notes as Note[]
|
||||
},
|
||||
|
||||
async createNote(
|
||||
keyPair: KeyPair,
|
||||
id: string,
|
||||
encryptedTitle: string,
|
||||
encryptedContent: string,
|
||||
iv: string
|
||||
): Promise<Note> {
|
||||
const payload: CreatePayload = {
|
||||
id,
|
||||
encrypted_title: encryptedTitle,
|
||||
encrypted_content: encryptedContent,
|
||||
iv,
|
||||
}
|
||||
const timestamp = Date.now()
|
||||
const body = JSON.stringify(payload)
|
||||
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||
const res = await fetch(`${baseUrl}/api/notes`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to create note: ${res.statusText}`)
|
||||
const data = await res.json()
|
||||
return data.note as Note
|
||||
},
|
||||
|
||||
async updateNote(
|
||||
keyPair: KeyPair,
|
||||
id: string,
|
||||
encryptedTitle: string,
|
||||
encryptedContent: string,
|
||||
iv: string
|
||||
): Promise<Note> {
|
||||
const payload: UpdatePayload = {
|
||||
encrypted_title: encryptedTitle,
|
||||
encrypted_content: encryptedContent,
|
||||
iv,
|
||||
}
|
||||
const timestamp = Date.now()
|
||||
const body = JSON.stringify(payload)
|
||||
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||
const res = await fetch(`${baseUrl}/api/notes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to update note: ${res.statusText}`)
|
||||
const data = await res.json()
|
||||
return data.note as Note
|
||||
},
|
||||
|
||||
async deleteNote(keyPair: KeyPair, id: string): Promise<void> {
|
||||
const timestamp = Date.now()
|
||||
const body = ''
|
||||
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||
const res = await fetch(`${baseUrl}/api/notes/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to delete note: ${res.statusText}`)
|
||||
},
|
||||
|
||||
getSSEUrl(keyPair: KeyPair): string {
|
||||
const timestamp = Date.now()
|
||||
const body = ''
|
||||
const signature = signRequest(keyPair.secretKey, timestamp, body)
|
||||
const nonce = crypto.randomUUID()
|
||||
return `${baseUrl}/api/notes/sync?publickey=${encodeURIComponent(keyPair.publicKey)}&signature=${encodeURIComponent(signature)}&nonce=${encodeURIComponent(nonce)}×tamp=${timestamp}`
|
||||
},
|
||||
|
||||
async listInvitations(keyPair: KeyPair): Promise<ShareInvitation[]> {
|
||||
const timestamp = Date.now()
|
||||
const body = ''
|
||||
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||
const res = await fetch(`${baseUrl}/api/invitations`, { headers })
|
||||
if (!res.ok) throw new Error(`Failed to list invitations: ${res.statusText}`)
|
||||
const data = await res.json()
|
||||
return (data.invitations as any[]).map((raw: any) => ({
|
||||
id: raw.id,
|
||||
fromPublicKey: raw.from_public_key,
|
||||
toPublicKey: raw.to_public_key,
|
||||
encryptedGroupKey: raw.encrypted_group_key,
|
||||
iv: raw.iv,
|
||||
noteId: raw.note_id,
|
||||
noteTitle: raw.note_title,
|
||||
created_at: raw.created_at,
|
||||
}))
|
||||
},
|
||||
|
||||
async sendInvitation(
|
||||
keyPair: KeyPair,
|
||||
toPublicKey: string,
|
||||
encryptedGroupKey?: string,
|
||||
iv?: string,
|
||||
noteId?: string,
|
||||
noteTitle?: string
|
||||
): Promise<ShareInvitation> {
|
||||
const payload: Record<string, string | undefined> = {
|
||||
toPublicKey,
|
||||
encryptedGroupKey: encryptedGroupKey ?? '',
|
||||
iv: iv ?? '',
|
||||
noteId,
|
||||
noteTitle,
|
||||
}
|
||||
const timestamp = Date.now()
|
||||
const body = JSON.stringify(payload)
|
||||
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||
const res = await fetch(`${baseUrl}/api/invitations`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to send invitation: ${res.statusText}`)
|
||||
const raw = await res.json()
|
||||
const inv = raw.invitation
|
||||
return {
|
||||
id: inv.id,
|
||||
fromPublicKey: inv.from_public_key,
|
||||
toPublicKey: inv.to_public_key,
|
||||
encryptedGroupKey: inv.encrypted_group_key,
|
||||
iv: inv.iv,
|
||||
noteId: inv.note_id,
|
||||
noteTitle: inv.note_title,
|
||||
created_at: inv.created_at,
|
||||
}
|
||||
},
|
||||
|
||||
async acceptInvitation(keyPair: KeyPair, invitationId: string): Promise<void> {
|
||||
const timestamp = Date.now()
|
||||
const body = ''
|
||||
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||
const res = await fetch(`${baseUrl}/api/invitations/${invitationId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to accept invitation: ${res.statusText}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
81
www/src/lib/crypto.ts
Normal file
81
www/src/lib/crypto.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
interface TweetNaclUtil {
|
||||
decodeUTF8: (s: string) => Uint8Array
|
||||
encodeUTF8: (arr: Uint8Array) => string
|
||||
encodeBase64: (arr: Uint8Array) => string
|
||||
decodeBase64: (s: string) => Uint8Array
|
||||
}
|
||||
|
||||
import tweetnaclUtil from 'tweetnacl-util'
|
||||
const util = tweetnaclUtil as unknown as {
|
||||
decodeUTF8: (s: string) => Uint8Array
|
||||
encodeUTF8: (arr: Uint8Array) => string
|
||||
encodeBase64: (arr: Uint8Array) => string
|
||||
decodeBase64: (s: string) => Uint8Array
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): Uint8Array {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
async function encryptData(aesKey: CryptoKey, plaintext: string, iv: Uint8Array): Promise<string> {
|
||||
const encoded = util.decodeUTF8(plaintext)
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
aesKey,
|
||||
encoded as BufferSource
|
||||
)
|
||||
return arrayBufferToBase64(ciphertext)
|
||||
}
|
||||
|
||||
async function decryptData(aesKey: CryptoKey, ciphertextBase64: string, iv: Uint8Array): Promise<string> {
|
||||
const ciphertext = base64ToArrayBuffer(ciphertextBase64)
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
aesKey,
|
||||
ciphertext as BufferSource
|
||||
)
|
||||
return util.encodeUTF8(new Uint8Array(decrypted))
|
||||
}
|
||||
|
||||
export async function encryptNote(
|
||||
aesKey: CryptoKey,
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<{ encryptedTitle: string; encryptedContent: string; iv: string }> {
|
||||
const ivTitle = new Uint8Array(12)
|
||||
const ivContent = new Uint8Array(12)
|
||||
window.crypto.getRandomValues(ivTitle)
|
||||
window.crypto.getRandomValues(ivContent)
|
||||
const encryptedTitle = await encryptData(aesKey, title, ivTitle)
|
||||
const encryptedContent = await encryptData(aesKey, content, ivContent)
|
||||
const combinedIv = arrayBufferToBase64(ivTitle.buffer as ArrayBuffer) + '|' + arrayBufferToBase64(ivContent.buffer as ArrayBuffer)
|
||||
return { encryptedTitle, encryptedContent, iv: combinedIv }
|
||||
}
|
||||
|
||||
export async function decryptNote(
|
||||
aesKey: CryptoKey,
|
||||
encryptedTitle: string,
|
||||
encryptedContent: string,
|
||||
ivBase64: string
|
||||
): Promise<{ title: string; content: string }> {
|
||||
const [ivTitleB64, ivContentB64] = ivBase64.split('|')
|
||||
const ivTitle = base64ToArrayBuffer(ivTitleB64)
|
||||
const ivContent = base64ToArrayBuffer(ivContentB64)
|
||||
const title = await decryptData(aesKey, encryptedTitle, ivTitle)
|
||||
const content = await decryptData(aesKey, encryptedContent, ivContent)
|
||||
return { title, content }
|
||||
}
|
||||
51
www/src/lib/keys.ts
Normal file
51
www/src/lib/keys.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import nacl from 'tweetnacl'
|
||||
import tweetnaclUtil from 'tweetnacl-util'
|
||||
import type { KeyPair } from '@/types'
|
||||
|
||||
const util = tweetnaclUtil as unknown as {
|
||||
decodeUTF8: (s: string) => Uint8Array
|
||||
encodeUTF8: (arr: Uint8Array) => string
|
||||
encodeBase64: (arr: Uint8Array) => string
|
||||
decodeBase64: (s: string) => Uint8Array
|
||||
}
|
||||
|
||||
export function generateKeyPair(): KeyPair {
|
||||
const pair = nacl.sign.keyPair()
|
||||
return {
|
||||
publicKey: util.encodeBase64(pair.publicKey),
|
||||
secretKey: util.encodeBase64(pair.secretKey),
|
||||
}
|
||||
}
|
||||
|
||||
export function importKeyPair(secretKeyBase64: string): KeyPair {
|
||||
const secretKey = util.decodeBase64(secretKeyBase64)
|
||||
const pair = nacl.sign.keyPair.fromSecretKey(secretKey)
|
||||
return {
|
||||
publicKey: util.encodeBase64(pair.publicKey),
|
||||
secretKey: secretKeyBase64,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deriveAESKey(secretKeyBase64: string): Promise<CryptoKey> {
|
||||
const secretKey = util.decodeBase64(secretKeyBase64)
|
||||
const rawKey = secretKey.slice(0, 32)
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
rawKey as BufferSource,
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: new Uint8Array(0),
|
||||
info: util.decodeUTF8('syncpad-aes-key') as BufferSource,
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
255
www/src/lib/sharing.ts
Normal file
255
www/src/lib/sharing.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import nacl from 'tweetnacl'
|
||||
import tweetnaclUtil from 'tweetnacl-util'
|
||||
import type { KeyPair } from '@/types'
|
||||
|
||||
const util = tweetnaclUtil as unknown as {
|
||||
decodeUTF8: (s: string) => Uint8Array
|
||||
encodeUTF8: (arr: Uint8Array) => string
|
||||
encodeBase64: (arr: Uint8Array) => string
|
||||
decodeBase64: (s: string) => Uint8Array
|
||||
}
|
||||
|
||||
const P = (1n << 255n) - 19n
|
||||
const D = 37095705934669439343138083508754565189542113879843219016388785533085940283555n
|
||||
|
||||
function mod(a: bigint): bigint {
|
||||
const r = a % P
|
||||
return r >= 0n ? r : r + P
|
||||
}
|
||||
|
||||
function modAdd(a: bigint, b: bigint): bigint {
|
||||
return mod(a + b)
|
||||
}
|
||||
|
||||
function modSub(a: bigint, b: bigint): bigint {
|
||||
return mod(a - b)
|
||||
}
|
||||
|
||||
function modMul(a: bigint, b: bigint): bigint {
|
||||
return mod(a * b)
|
||||
}
|
||||
|
||||
function modPow(base: bigint, exp: bigint): bigint {
|
||||
let result = 1n
|
||||
base = mod(base)
|
||||
while (exp > 0n) {
|
||||
if (exp & 1n) result = modMul(result, base)
|
||||
base = modMul(base, base)
|
||||
exp >>= 1n
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function modInv(a: bigint): bigint {
|
||||
return modPow(a, P - 2n)
|
||||
}
|
||||
|
||||
function modSqrt(a: bigint): bigint {
|
||||
const v = modPow(modMul(2n, a), (P - 5n) / 8n)
|
||||
const i = modMul(modMul(2n, a), modMul(v, v))
|
||||
return modMul(modMul(a, v), modSub(i, 1n))
|
||||
}
|
||||
|
||||
function bytesToBigIntLE(bytes: Uint8Array): bigint {
|
||||
let result = 0n
|
||||
for (let i = bytes.length - 1; i >= 0; i--) {
|
||||
result = (result << 8n) | BigInt(bytes[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function bigIntToBytesLE(value: bigint, length: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length)
|
||||
let v = value
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = Number(v & 0xffn)
|
||||
v >>= 8n
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function decodeEd25519Point(pubKey: Uint8Array): { x: bigint; y: bigint } {
|
||||
const bytes = new Uint8Array(pubKey)
|
||||
const signBit = bytes[31] >>> 7
|
||||
bytes[31] &= 0x7f
|
||||
|
||||
const y = bytesToBigIntLE(bytes)
|
||||
const y2 = modMul(y, y)
|
||||
const num = modSub(y2, 1n)
|
||||
const denom = modAdd(modMul(D, y2), 1n)
|
||||
const x2 = modMul(num, modInv(denom))
|
||||
|
||||
let x = modSqrt(x2)
|
||||
if ((x & 1n) !== BigInt(signBit)) {
|
||||
x = modSub(P, x)
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function edwardsToMontgomery(edPubKey: Uint8Array): Uint8Array {
|
||||
const point = decodeEd25519Point(edPubKey)
|
||||
const u = modMul(modAdd(1n, point.y), modInv(modSub(1n, point.y)))
|
||||
return bigIntToBytesLE(u, 32)
|
||||
}
|
||||
|
||||
async function deriveX25519Scalar(edSecretKey: Uint8Array): Promise<Uint8Array> {
|
||||
const seed = edSecretKey.slice(0, 32)
|
||||
const hash = new Uint8Array(await crypto.subtle.digest('SHA-512', seed))
|
||||
const scalar = hash.slice(0, 32)
|
||||
scalar[0] &= 248
|
||||
scalar[31] &= 127
|
||||
scalar[31] |= 64
|
||||
return scalar
|
||||
}
|
||||
|
||||
export function encodePublicKeyLink(publicKey: string): string {
|
||||
return `syncpad://invite?pk=${encodeURIComponent(publicKey)}`
|
||||
}
|
||||
|
||||
export function decodePublicKeyLink(link: string): string | null {
|
||||
try {
|
||||
const url = new URL(link)
|
||||
if (url.protocol === 'syncpad:') {
|
||||
return url.searchParams.get('pk')
|
||||
}
|
||||
} catch {}
|
||||
const idx = link.indexOf('pk=')
|
||||
if (idx !== -1) {
|
||||
return decodeURIComponent(link.slice(idx + 3).split('&')[0])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function deriveSharedSecret(
|
||||
mySecretKeyBase64: string,
|
||||
theirPublicKeyBase64: string
|
||||
): Promise<Uint8Array> {
|
||||
const mySecretKey = util.decodeBase64(mySecretKeyBase64)
|
||||
const theirPublicKey = util.decodeBase64(theirPublicKeyBase64)
|
||||
|
||||
if (theirPublicKey.length !== 32) {
|
||||
throw new Error('Invalid public key length')
|
||||
}
|
||||
|
||||
const myScalar = await deriveX25519Scalar(mySecretKey)
|
||||
const theirMontgomery = edwardsToMontgomery(theirPublicKey)
|
||||
|
||||
return nacl.scalarMult(myScalar, theirMontgomery)
|
||||
}
|
||||
|
||||
export async function deriveSharedKeyPair(sharedSecret: Uint8Array): Promise<KeyPair> {
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedSecret,
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveBits']
|
||||
)
|
||||
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: new Uint8Array(0),
|
||||
info: util.decodeUTF8('syncpad-shared-key'),
|
||||
},
|
||||
baseKey,
|
||||
256
|
||||
)
|
||||
|
||||
const seed = new Uint8Array(derivedBits)
|
||||
const pair = nacl.sign.keyPair.fromSeed(seed)
|
||||
|
||||
return {
|
||||
publicKey: util.encodeBase64(pair.publicKey),
|
||||
secretKey: util.encodeBase64(pair.secretKey),
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateGroupSecret(): Promise<{ secret: Uint8Array; secretBase64: string }> {
|
||||
const secret = new Uint8Array(32)
|
||||
crypto.getRandomValues(secret)
|
||||
return { secret, secretBase64: util.encodeBase64(secret) }
|
||||
}
|
||||
|
||||
export async function derivePairwiseWrappingKey(
|
||||
sharedSecret: Uint8Array,
|
||||
memberPublicKeyBase64: string
|
||||
): Promise<CryptoKey> {
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedSecret,
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: new Uint8Array(0),
|
||||
info: util.decodeUTF8(`syncpad-group-wrap:${memberPublicKeyBase64}`),
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
export async function encryptGroupSecretForMember(
|
||||
groupSecret: Uint8Array,
|
||||
mySecretKeyBase64: string,
|
||||
memberPublicKeyBase64: string
|
||||
): Promise<{ encryptedKey: string; iv: string }> {
|
||||
const sharedSecret = await deriveSharedSecret(mySecretKeyBase64, memberPublicKeyBase64)
|
||||
const wrappingKey = await derivePairwiseWrappingKey(sharedSecret, memberPublicKeyBase64)
|
||||
|
||||
const iv = new Uint8Array(12)
|
||||
crypto.getRandomValues(iv)
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
wrappingKey,
|
||||
groupSecret
|
||||
)
|
||||
|
||||
return {
|
||||
encryptedKey: util.encodeBase64(new Uint8Array(ciphertext)),
|
||||
iv: util.encodeBase64(iv),
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptGroupSecret(
|
||||
encryptedKeyBase64: string,
|
||||
ivBase64: string,
|
||||
mySecretKeyBase64: string,
|
||||
creatorPublicKeyBase64: string
|
||||
): Promise<Uint8Array> {
|
||||
const sharedSecret = await deriveSharedSecret(mySecretKeyBase64, creatorPublicKeyBase64)
|
||||
const wrappingKey = await derivePairwiseWrappingKey(sharedSecret, creatorPublicKeyBase64)
|
||||
|
||||
const ciphertext = util.decodeBase64(encryptedKeyBase64)
|
||||
const iv = util.decodeBase64(ivBase64)
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
wrappingKey,
|
||||
ciphertext
|
||||
)
|
||||
|
||||
return new Uint8Array(decrypted)
|
||||
}
|
||||
|
||||
export function getSharedIdentityId(myPublicKey: string, theirPublicKey: string): string {
|
||||
const keys = [myPublicKey, theirPublicKey].sort()
|
||||
return `shared:${keys[0].slice(0, 16)}:${keys[1].slice(0, 16)}`
|
||||
}
|
||||
|
||||
export function getGroupIdentityId(creatorPublicKey: string, memberPublicKeys: string[]): string {
|
||||
const all = [creatorPublicKey, ...memberPublicKeys].sort()
|
||||
const hash = all.join(',').slice(0, 32)
|
||||
return `group:${hash}`
|
||||
}
|
||||
16
www/src/lib/signature.ts
Normal file
16
www/src/lib/signature.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import nacl from 'tweetnacl'
|
||||
import tweetnaclUtil from 'tweetnacl-util'
|
||||
|
||||
const util = tweetnaclUtil as unknown as {
|
||||
decodeUTF8: (s: string) => Uint8Array
|
||||
encodeUTF8: (arr: Uint8Array) => string
|
||||
encodeBase64: (arr: Uint8Array) => string
|
||||
decodeBase64: (s: string) => Uint8Array
|
||||
}
|
||||
|
||||
export function signRequest(secretKeyBase64: string, timestamp: number, body: string): string {
|
||||
const secretKey = util.decodeBase64(secretKeyBase64)
|
||||
const message = util.decodeUTF8(`${timestamp}:${body}`)
|
||||
const signature = nacl.sign.detached(message, secretKey)
|
||||
return util.encodeBase64(signature)
|
||||
}
|
||||
67
www/src/lib/sse.ts
Normal file
67
www/src/lib/sse.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Note, ShareInvitation } from '@/types'
|
||||
|
||||
export interface SSEHandlers {
|
||||
onNoteCreated: (note: Note) => void
|
||||
onNoteUpdated: (note: Note) => void
|
||||
onNoteDeleted: (data: { id: string }) => void
|
||||
onInvitationReceived?: (invitation: ShareInvitation) => void
|
||||
onError: (err: Event) => void
|
||||
}
|
||||
|
||||
export function createSSEClient(url: string, handlers: SSEHandlers): { close: () => void } {
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
eventSource.addEventListener('note_created', (event: MessageEvent) => {
|
||||
try {
|
||||
const note: Note = JSON.parse(event.data)
|
||||
handlers.onNoteCreated(note)
|
||||
} catch {
|
||||
handlers.onError(new Event('parse_error'))
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('note_updated', (event: MessageEvent) => {
|
||||
try {
|
||||
const note: Note = JSON.parse(event.data)
|
||||
handlers.onNoteUpdated(note)
|
||||
} catch {
|
||||
handlers.onError(new Event('parse_error'))
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('note_deleted', (event: MessageEvent) => {
|
||||
try {
|
||||
const data: { id: string } = JSON.parse(event.data)
|
||||
handlers.onNoteDeleted(data)
|
||||
} catch {
|
||||
handlers.onError(new Event('parse_error'))
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('invitation_received', (event: MessageEvent) => {
|
||||
try {
|
||||
const raw = JSON.parse(event.data)
|
||||
const invitation: ShareInvitation = {
|
||||
id: raw.id,
|
||||
fromPublicKey: raw.from_public_key,
|
||||
toPublicKey: raw.to_public_key,
|
||||
encryptedGroupKey: raw.encrypted_group_key,
|
||||
iv: raw.iv,
|
||||
noteId: raw.note_id,
|
||||
noteTitle: raw.note_title,
|
||||
created_at: raw.created_at,
|
||||
}
|
||||
handlers.onInvitationReceived?.(invitation)
|
||||
} catch {
|
||||
handlers.onError(new Event('parse_error'))
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.onerror = (err: Event) => {
|
||||
handlers.onError(err)
|
||||
}
|
||||
|
||||
return {
|
||||
close: () => eventSource.close(),
|
||||
}
|
||||
}
|
||||
7
www/src/lib/utils.ts
Normal file
7
www/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx"
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
11
www/src/main.ts
Normal file
11
www/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
13
www/src/router/index.ts
Normal file
13
www/src/router/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import SetupView from '@/views/SetupView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/setup', name: 'setup', component: SetupView },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
274
www/src/stores/auth.ts
Normal file
274
www/src/stores/auth.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { KeyPair, Identity, Contact } from '@/types'
|
||||
import { generateKeyPair, importKeyPair, deriveAESKey } from '@/lib/keys'
|
||||
import { deriveSharedSecret, deriveSharedKeyPair, getSharedIdentityId } from '@/lib/sharing'
|
||||
|
||||
const LEGACY_STORAGE_KEY = 'syncpad_secret_key'
|
||||
const IDENTITIES_STORAGE_KEY = 'syncpad_identities'
|
||||
const CONTACTS_STORAGE_KEY = 'syncpad_contacts'
|
||||
const ACTIVE_ID_KEY = 'syncpad_active_id'
|
||||
|
||||
interface StoredIdentity {
|
||||
id: string
|
||||
label: string
|
||||
secretKey: string
|
||||
isPrimary: boolean
|
||||
type: 'personal' | 'shared' | 'group'
|
||||
contacts?: string[]
|
||||
}
|
||||
|
||||
const identities = ref<Identity[]>([])
|
||||
const contacts = ref<Contact[]>([])
|
||||
const activeIdentityId = ref<string>('primary')
|
||||
const ready = ref(false)
|
||||
|
||||
const activeIdentity = computed(() =>
|
||||
identities.value.find((i) => i.id === activeIdentityId.value) ?? null
|
||||
)
|
||||
|
||||
const primaryIdentity = computed(() =>
|
||||
identities.value.find((i) => i.isPrimary) ?? null
|
||||
)
|
||||
|
||||
const keyPair = computed<KeyPair | null>(() =>
|
||||
activeIdentity.value?.keyPair ?? null
|
||||
)
|
||||
|
||||
const aesKey = computed<CryptoKey | null>(() =>
|
||||
activeIdentity.value?.aesKey ?? null
|
||||
)
|
||||
|
||||
const isSetup = computed(() =>
|
||||
identities.value.some((i) => i.isPrimary)
|
||||
)
|
||||
|
||||
function persistIdentities() {
|
||||
const stored: StoredIdentity[] = identities.value.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.label,
|
||||
secretKey: i.keyPair.secretKey,
|
||||
isPrimary: i.isPrimary,
|
||||
type: i.type,
|
||||
contacts: i.contacts,
|
||||
}))
|
||||
localStorage.setItem(IDENTITIES_STORAGE_KEY, JSON.stringify(stored))
|
||||
}
|
||||
|
||||
function persistContacts() {
|
||||
localStorage.setItem(CONTACTS_STORAGE_KEY, JSON.stringify(contacts.value))
|
||||
}
|
||||
|
||||
async function deriveIdentityAESKeys() {
|
||||
for (const identity of identities.value) {
|
||||
if (!identity.aesKey) {
|
||||
identity.aesKey = await deriveAESKey(identity.keyPair.secretKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSharedIdentity(contactPublicKey: string): Promise<Identity | null> {
|
||||
const primary = primaryIdentity.value
|
||||
if (!primary) return null
|
||||
|
||||
const sharedId = getSharedIdentityId(primary.keyPair.publicKey, contactPublicKey)
|
||||
const existing = identities.value.find((i) => i.id === sharedId)
|
||||
if (existing) return existing
|
||||
|
||||
const contact = contacts.value.find((c) => c.publicKey === contactPublicKey)
|
||||
const label = contact ? `Shared with ${contact.label}` : 'Shared'
|
||||
|
||||
const sharedSecret = await deriveSharedSecret(
|
||||
primary.keyPair.secretKey,
|
||||
contactPublicKey
|
||||
)
|
||||
const sharedKeyPair = await deriveSharedKeyPair(sharedSecret)
|
||||
const aesKey = await deriveAESKey(sharedKeyPair.secretKey)
|
||||
|
||||
const identity: Identity = {
|
||||
id: sharedId,
|
||||
label,
|
||||
keyPair: sharedKeyPair,
|
||||
aesKey,
|
||||
isPrimary: false,
|
||||
type: 'shared',
|
||||
contacts: [contactPublicKey],
|
||||
}
|
||||
|
||||
identities.value.push(identity)
|
||||
persistIdentities()
|
||||
return identity
|
||||
}
|
||||
|
||||
export function useAuthStore() {
|
||||
async function setupKeyPair(pair: KeyPair): Promise<void> {
|
||||
const existing = identities.value.find((i) => i.isPrimary)
|
||||
if (existing) {
|
||||
existing.keyPair = pair
|
||||
existing.aesKey = await deriveAESKey(pair.secretKey)
|
||||
} else {
|
||||
const aesKey = await deriveAESKey(pair.secretKey)
|
||||
identities.value.push({
|
||||
id: 'primary',
|
||||
label: 'My Notes',
|
||||
keyPair: pair,
|
||||
aesKey,
|
||||
isPrimary: true,
|
||||
type: 'personal',
|
||||
})
|
||||
}
|
||||
activeIdentityId.value = 'primary'
|
||||
persistIdentities()
|
||||
localStorage.setItem(ACTIVE_ID_KEY, 'primary')
|
||||
}
|
||||
|
||||
async function loadFromStorage(): Promise<boolean> {
|
||||
try {
|
||||
identities.value = []
|
||||
contacts.value = []
|
||||
const stored = localStorage.getItem(IDENTITIES_STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed: StoredIdentity[] = JSON.parse(stored)
|
||||
for (const s of parsed) {
|
||||
identities.value.push({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
keyPair: importKeyPair(s.secretKey),
|
||||
aesKey: null,
|
||||
isPrimary: s.isPrimary,
|
||||
type: s.type,
|
||||
contacts: s.contacts,
|
||||
})
|
||||
}
|
||||
await deriveIdentityAESKeys()
|
||||
|
||||
const savedActiveId = localStorage.getItem(ACTIVE_ID_KEY)
|
||||
if (savedActiveId && identities.value.some((i) => i.id === savedActiveId)) {
|
||||
activeIdentityId.value = savedActiveId
|
||||
}
|
||||
} else {
|
||||
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
|
||||
if (legacy) {
|
||||
const pair = importKeyPair(legacy)
|
||||
identities.value.push({
|
||||
id: 'primary',
|
||||
label: 'My Notes',
|
||||
keyPair: pair,
|
||||
aesKey: null,
|
||||
isPrimary: true,
|
||||
type: 'personal',
|
||||
})
|
||||
await deriveIdentityAESKeys()
|
||||
persistIdentities()
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY)
|
||||
localStorage.setItem(ACTIVE_ID_KEY, 'primary')
|
||||
}
|
||||
}
|
||||
|
||||
const storedContacts = localStorage.getItem(CONTACTS_STORAGE_KEY)
|
||||
if (storedContacts) {
|
||||
contacts.value = JSON.parse(storedContacts)
|
||||
}
|
||||
|
||||
if (primaryIdentity.value) {
|
||||
for (const contact of contacts.value) {
|
||||
await ensureSharedIdentity(contact.publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
ready.value = true
|
||||
return isSetup.value
|
||||
} catch {
|
||||
localStorage.removeItem(IDENTITIES_STORAGE_KEY)
|
||||
localStorage.removeItem(CONTACTS_STORAGE_KEY)
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY)
|
||||
identities.value = []
|
||||
contacts.value = []
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function clearKeys(): void {
|
||||
identities.value = []
|
||||
contacts.value = []
|
||||
activeIdentityId.value = 'primary'
|
||||
localStorage.removeItem(IDENTITIES_STORAGE_KEY)
|
||||
localStorage.removeItem(CONTACTS_STORAGE_KEY)
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY)
|
||||
localStorage.removeItem(ACTIVE_ID_KEY)
|
||||
}
|
||||
|
||||
function switchIdentity(id: string): void {
|
||||
if (id === '__all__' || identities.value.some((i) => i.id === id)) {
|
||||
activeIdentityId.value = id
|
||||
localStorage.setItem(ACTIVE_ID_KEY, id)
|
||||
}
|
||||
}
|
||||
|
||||
async function addContact(publicKey: string, label: string): Promise<void> {
|
||||
const existing = contacts.value.find((c) => c.publicKey === publicKey)
|
||||
if (existing) {
|
||||
existing.label = label
|
||||
} else {
|
||||
contacts.value.push({ publicKey, label, addedAt: Date.now() })
|
||||
}
|
||||
persistContacts()
|
||||
|
||||
const identity = await ensureSharedIdentity(publicKey)
|
||||
if (identity && activeIdentityId.value === 'primary') {
|
||||
}
|
||||
}
|
||||
|
||||
function renameContact(publicKey: string, newLabel: string): void {
|
||||
const contact = contacts.value.find((c) => c.publicKey === publicKey)
|
||||
if (contact) {
|
||||
contact.label = newLabel
|
||||
persistContacts()
|
||||
}
|
||||
const primary = primaryIdentity.value
|
||||
if (primary) {
|
||||
const sharedId = getSharedIdentityId(primary.keyPair.publicKey, publicKey)
|
||||
const identity = identities.value.find((i) => i.id === sharedId)
|
||||
if (identity) {
|
||||
identity.label = `Shared with ${newLabel}`
|
||||
persistIdentities()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeContact(publicKey: string): void {
|
||||
contacts.value = contacts.value.filter((c) => c.publicKey !== publicKey)
|
||||
persistContacts()
|
||||
const sharedId = primaryIdentity.value
|
||||
? getSharedIdentityId(primaryIdentity.value.keyPair.publicKey, publicKey)
|
||||
: null
|
||||
if (sharedId) {
|
||||
identities.value = identities.value.filter((i) => i.id !== sharedId)
|
||||
persistIdentities()
|
||||
if (activeIdentityId.value === sharedId) {
|
||||
switchIdentity('primary')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
keyPair,
|
||||
aesKey,
|
||||
isSetup,
|
||||
identities,
|
||||
contacts,
|
||||
activeIdentity,
|
||||
activeIdentityId,
|
||||
primaryIdentity,
|
||||
ready,
|
||||
setupKeyPair,
|
||||
loadFromStorage,
|
||||
clearKeys,
|
||||
switchIdentity,
|
||||
addContact,
|
||||
renameContact,
|
||||
removeContact,
|
||||
ensureSharedIdentity,
|
||||
generateKeyPair,
|
||||
importKeyPair,
|
||||
}
|
||||
}
|
||||
235
www/src/stores/notes.ts
Normal file
235
www/src/stores/notes.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import type { Note, DecryptedNote, KeyPair } from '@/types'
|
||||
import { createApiClient } from '@/lib/api'
|
||||
import { encryptNote, decryptNote } from '@/lib/crypto'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const api = createApiClient(API_URL)
|
||||
|
||||
const notesCache = reactive<Record<string, DecryptedNote[]>>({})
|
||||
const selectedNoteId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
function getActivePubKey(): string | null {
|
||||
const auth = useAuthStore()
|
||||
return auth.activeIdentity.value?.keyPair.publicKey ?? null
|
||||
}
|
||||
|
||||
const notes = computed<DecryptedNote[]>(() => {
|
||||
const auth = useAuthStore()
|
||||
if (auth.activeIdentityId.value === '__all__') {
|
||||
const all: DecryptedNote[] = []
|
||||
for (const key of Object.keys(notesCache)) {
|
||||
all.push(...(notesCache[key] || []))
|
||||
}
|
||||
all.sort((a, b) => b.updated_at - a.updated_at)
|
||||
return all
|
||||
}
|
||||
const pubKey = getActivePubKey()
|
||||
if (!pubKey) return []
|
||||
return notesCache[pubKey] ?? []
|
||||
})
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
export function useNotesStore() {
|
||||
async function fetchNotes(
|
||||
keyPair: KeyPair,
|
||||
aesKey: CryptoKey
|
||||
): Promise<void> {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const rawNotes = await api.listNotes(keyPair)
|
||||
const decrypted: DecryptedNote[] = []
|
||||
for (const note of rawNotes) {
|
||||
try {
|
||||
const dec = await decryptNote(
|
||||
aesKey,
|
||||
note.encrypted_title,
|
||||
note.encrypted_content,
|
||||
note.iv
|
||||
)
|
||||
decrypted.push({
|
||||
id: note.id,
|
||||
title: dec.title,
|
||||
content: dec.content,
|
||||
created_at: note.created_at,
|
||||
updated_at: note.updated_at,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
decrypted.sort((a, b) => b.updated_at - a.updated_at)
|
||||
notesCache[keyPair.publicKey] = decrypted
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote(
|
||||
keyPair: KeyPair,
|
||||
aesKey: CryptoKey,
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<DecryptedNote> {
|
||||
const id = generateId()
|
||||
const { encryptedTitle, encryptedContent, iv } = await encryptNote(aesKey, title, content)
|
||||
const raw = await api.createNote(keyPair, id, encryptedTitle, encryptedContent, iv)
|
||||
const decrypted: DecryptedNote = {
|
||||
id: raw.id,
|
||||
title,
|
||||
content,
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
}
|
||||
|
||||
const pubKey = keyPair.publicKey
|
||||
if (!notesCache[pubKey]) notesCache[pubKey] = []
|
||||
if (!notesCache[pubKey].find((n) => n.id === decrypted.id)) {
|
||||
notesCache[pubKey].unshift(decrypted)
|
||||
}
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
async function updateNote(
|
||||
keyPair: KeyPair,
|
||||
aesKey: CryptoKey,
|
||||
id: string,
|
||||
title: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const { encryptedTitle, encryptedContent, iv } = await encryptNote(aesKey, title, content)
|
||||
const raw = await api.updateNote(keyPair, id, encryptedTitle, encryptedContent, iv)
|
||||
|
||||
const pubKey = keyPair.publicKey
|
||||
const list = notesCache[pubKey]
|
||||
if (!list) return
|
||||
|
||||
const idx = list.findIndex((n) => n.id === id)
|
||||
if (idx !== -1) {
|
||||
list[idx] = {
|
||||
...list[idx],
|
||||
title,
|
||||
content,
|
||||
updated_at: raw.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(keyPair: KeyPair, id: string): Promise<void> {
|
||||
await api.deleteNote(keyPair, id)
|
||||
|
||||
const pubKey = keyPair.publicKey
|
||||
const list = notesCache[pubKey]
|
||||
if (!list) return
|
||||
|
||||
notesCache[pubKey] = list.filter((n) => n.id !== id)
|
||||
|
||||
if (selectedNoteId.value === id) {
|
||||
selectedNoteId.value = notesCache[pubKey].length > 0
|
||||
? notesCache[pubKey][0].id
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function selectNote(id: string | null): void {
|
||||
selectedNoteId.value = id
|
||||
}
|
||||
|
||||
function clearActiveSelection(): void {
|
||||
selectedNoteId.value = null
|
||||
}
|
||||
|
||||
async function ensureNotesLoaded(keyPair: KeyPair, aesKey: CryptoKey): Promise<void> {
|
||||
if (!notesCache[keyPair.publicKey]) {
|
||||
await fetchNotes(keyPair, aesKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNoteCreated(raw: Note, aesKey: CryptoKey): Promise<void> {
|
||||
const pubKey = raw.public_key
|
||||
if (!notesCache[pubKey]) notesCache[pubKey] = []
|
||||
|
||||
if (notesCache[pubKey].find((n) => n.id === raw.id)) return
|
||||
|
||||
try {
|
||||
const dec = await decryptNote(aesKey, raw.encrypted_title, raw.encrypted_content, raw.iv)
|
||||
notesCache[pubKey].unshift({
|
||||
id: raw.id,
|
||||
title: dec.title,
|
||||
content: dec.content,
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
})
|
||||
notesCache[pubKey].sort((a, b) => b.updated_at - a.updated_at)
|
||||
} catch { /* skip notes we can't decrypt */ }
|
||||
}
|
||||
|
||||
async function handleNoteUpdated(raw: Note, aesKey: CryptoKey): Promise<void> {
|
||||
const pubKey = raw.public_key
|
||||
if (!notesCache[pubKey]) notesCache[pubKey] = []
|
||||
|
||||
try {
|
||||
const dec = await decryptNote(aesKey, raw.encrypted_title, raw.encrypted_content, raw.iv)
|
||||
const idx = notesCache[pubKey].findIndex((n) => n.id === raw.id)
|
||||
|
||||
const decrypted: DecryptedNote = {
|
||||
id: raw.id,
|
||||
title: dec.title,
|
||||
content: dec.content,
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
}
|
||||
|
||||
if (idx !== -1) {
|
||||
notesCache[pubKey][idx] = decrypted
|
||||
} else {
|
||||
notesCache[pubKey].unshift(decrypted)
|
||||
}
|
||||
notesCache[pubKey].sort((a, b) => b.updated_at - a.updated_at)
|
||||
} catch { /* skip notes we can't decrypt */ }
|
||||
}
|
||||
|
||||
function handleNoteDeleted(data: { id: string; public_key?: string }): void {
|
||||
if (data.public_key && notesCache[data.public_key]) {
|
||||
notesCache[data.public_key] = notesCache[data.public_key].filter((n) => n.id !== data.id)
|
||||
} else {
|
||||
for (const key of Object.keys(notesCache)) {
|
||||
notesCache[key] = notesCache[key].filter((n) => n.id !== data.id)
|
||||
}
|
||||
}
|
||||
if (selectedNoteId.value === data.id) {
|
||||
const pubKey = getActivePubKey()
|
||||
const list = pubKey ? notesCache[pubKey] ?? [] : []
|
||||
selectedNoteId.value = list.length > 0 ? list[0].id : null
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateCache(pubKey: string): void {
|
||||
delete notesCache[pubKey]
|
||||
}
|
||||
|
||||
return {
|
||||
notes,
|
||||
notesCache,
|
||||
selectedNoteId,
|
||||
isLoading,
|
||||
fetchNotes,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
selectNote,
|
||||
clearActiveSelection,
|
||||
ensureNotesLoaded,
|
||||
handleNoteCreated,
|
||||
handleNoteUpdated,
|
||||
handleNoteDeleted,
|
||||
invalidateCache,
|
||||
api,
|
||||
}
|
||||
}
|
||||
225
www/src/style.css
Normal file
225
www/src/style.css
Normal file
@@ -0,0 +1,225 @@
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@import "shadcn-vue/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown editor styles */
|
||||
.markdown-body {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-body h1 { font-size: 2em; font-weight: 700; margin: 0.67em 0; }
|
||||
.markdown-body h2 { font-size: 1.5em; font-weight: 600; margin: 0.75em 0; }
|
||||
.markdown-body h3 { font-size: 1.25em; font-weight: 600; margin: 0.83em 0; }
|
||||
.markdown-body h4 { font-size: 1em; font-weight: 600; margin: 1em 0; }
|
||||
.markdown-body p { margin: 0.5em 0; }
|
||||
.markdown-body ul, .markdown-body ol { padding-left: 2em; margin: 0.5em 0; }
|
||||
.markdown-body li { margin: 0.25em 0; }
|
||||
.markdown-body code { background: hsl(var(--muted)); padding: 0.2em 0.4em; border-radius: 0.25em; font-size: 0.9em; }
|
||||
.markdown-body pre { background: hsl(var(--muted)); padding: 1em; border-radius: 0.5em; overflow-x: auto; margin: 0.5em 0; }
|
||||
.markdown-body pre code { background: none; padding: 0; }
|
||||
.markdown-body blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1em; margin: 0.5em 0; color: hsl(var(--muted-foreground)); }
|
||||
.markdown-body a { color: hsl(var(--primary)); text-decoration: underline; }
|
||||
.markdown-body hr { border: none; border-top: 1px solid hsl(var(--border)); margin: 1em 0; }
|
||||
.markdown-body table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||
.markdown-body th, .markdown-body td { border: 1px solid hsl(var(--border)); padding: 0.5em; text-align: left; }
|
||||
.markdown-body th { background: hsl(var(--muted)); font-weight: 600; }
|
||||
.markdown-body img { max-width: 100%; }
|
||||
|
||||
/* Editor styles */
|
||||
.editor-textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-left {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in-from-left 0.3s ease-out;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
54
www/src/types.ts
Normal file
54
www/src/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface Note {
|
||||
id: string;
|
||||
public_key: string;
|
||||
encrypted_content: string;
|
||||
encrypted_title: string;
|
||||
iv: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface DecryptedNote {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface KeyPair {
|
||||
publicKey: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
publicKey: string
|
||||
label: string
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
id: string
|
||||
label: string
|
||||
keyPair: KeyPair
|
||||
aesKey: CryptoKey | null
|
||||
isPrimary: boolean
|
||||
contacts?: string[]
|
||||
type: 'personal' | 'shared' | 'group'
|
||||
}
|
||||
|
||||
export interface EncryptedGroupKey {
|
||||
memberPublicKey: string
|
||||
encryptedKey: string
|
||||
iv: string
|
||||
}
|
||||
|
||||
export interface ShareInvitation {
|
||||
id: string
|
||||
fromPublicKey: string
|
||||
toPublicKey: string
|
||||
encryptedGroupKey: EncryptedGroupKey
|
||||
noteId?: string
|
||||
noteTitle?: string
|
||||
created_at: number
|
||||
}
|
||||
407
www/src/views/HomeView.vue
Normal file
407
www/src/views/HomeView.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { createSSEClient } from '@/lib/sse'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import NoteEditor from '@/components/note/NoteEditor.vue'
|
||||
import NotePreview from '@/components/note/NotePreview.vue'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Menu, Shield, Plus, Columns2, Pencil, Eye, HelpCircle, User, Users, Share2 } from 'lucide-vue-next'
|
||||
import NoteList from '@/components/note/NoteList.vue'
|
||||
import MdHelpDialog from '@/components/note/MdHelpDialog.vue'
|
||||
import ShareDialog from '@/components/note/ShareDialog.vue'
|
||||
|
||||
import type { DecryptedNote, KeyPair } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const store = useNotesStore()
|
||||
|
||||
const title = ref('')
|
||||
const sseConnections = new Map<string, { close: () => void }>()
|
||||
const sheetOpen = ref(false)
|
||||
const mobileTab = ref('edit')
|
||||
const desktopMode = ref('edit')
|
||||
const previewContent = ref('')
|
||||
const mdHelpOpen = ref(false)
|
||||
const shareDialogOpen = ref(false)
|
||||
|
||||
const selectedNote = computed<DecryptedNote | null>(() => {
|
||||
if (!store.selectedNoteId.value) return null
|
||||
return store.notes.value.find((n) => n.id === store.selectedNoteId.value) ?? null
|
||||
})
|
||||
|
||||
function handleContentChange(content: string) {
|
||||
previewContent.value = content
|
||||
}
|
||||
|
||||
function ensureSSEConnected(keyPair: KeyPair) {
|
||||
const pubKey = keyPair.publicKey
|
||||
if (sseConnections.has(pubKey)) return
|
||||
|
||||
const url = store.api.getSSEUrl(keyPair)
|
||||
const client = createSSEClient(url, {
|
||||
onNoteCreated(note) {
|
||||
const identity = auth.identities.value.find(
|
||||
(i) => i.keyPair.publicKey === note.public_key
|
||||
)
|
||||
if (identity?.aesKey) {
|
||||
store.handleNoteCreated(note, identity.aesKey)
|
||||
}
|
||||
},
|
||||
onNoteUpdated(note) {
|
||||
const identity = auth.identities.value.find(
|
||||
(i) => i.keyPair.publicKey === note.public_key
|
||||
)
|
||||
if (identity?.aesKey) {
|
||||
store.handleNoteUpdated(note, identity.aesKey)
|
||||
}
|
||||
},
|
||||
onNoteDeleted(data) {
|
||||
store.handleNoteDeleted(data)
|
||||
},
|
||||
async onInvitationReceived(invitation) {
|
||||
await processInvitation(invitation)
|
||||
},
|
||||
onError() {},
|
||||
})
|
||||
sseConnections.set(pubKey, client)
|
||||
}
|
||||
|
||||
function disconnectAllSSE() {
|
||||
for (const [, client] of sseConnections) {
|
||||
client.close()
|
||||
}
|
||||
sseConnections.clear()
|
||||
}
|
||||
|
||||
async function connectAllSSE() {
|
||||
for (const identity of auth.identities.value) {
|
||||
ensureSSEConnected(identity.keyPair)
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const loaded = await auth.loadFromStorage()
|
||||
if (!loaded) {
|
||||
console.log('No keys found, redirecting to setup')
|
||||
router.replace('/setup')
|
||||
return
|
||||
}
|
||||
const kp = auth.keyPair.value ?? auth.primaryIdentity.value?.keyPair
|
||||
const ak = auth.aesKey.value ?? auth.primaryIdentity.value?.aesKey
|
||||
if (!kp || !ak) return
|
||||
await store.fetchNotes(kp, ak)
|
||||
connectAllSSE()
|
||||
pollInvitations()
|
||||
}
|
||||
|
||||
async function processInvitation(invitation: { id: string; fromPublicKey: string; toPublicKey: string; encryptedGroupKey: string; iv: string; noteTitle?: string }) {
|
||||
const primary = auth.primaryIdentity.value
|
||||
if (!primary?.keyPair) return
|
||||
|
||||
const existingContact = auth.contacts.value.find((c) => c.publicKey === invitation.fromPublicKey)
|
||||
if (existingContact) {
|
||||
try {
|
||||
await store.api.acceptInvitation(primary.keyPair, invitation.id)
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const label = invitation.noteTitle || 'Shared Contact'
|
||||
await auth.addContact(invitation.fromPublicKey, label)
|
||||
await store.api.acceptInvitation(primary.keyPair, invitation.id)
|
||||
} catch {
|
||||
console.error('Failed to process invitation')
|
||||
}
|
||||
}
|
||||
|
||||
async function pollInvitations() {
|
||||
const primary = auth.primaryIdentity.value
|
||||
if (!primary?.keyPair) return
|
||||
try {
|
||||
const invitations = await store.api.listInvitations(primary.keyPair)
|
||||
for (const inv of invitations) {
|
||||
await processInvitation(inv)
|
||||
}
|
||||
} catch {
|
||||
// invitations endpoint may not be available yet
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchIdentity(identityId: string) {
|
||||
if (identityId === auth.activeIdentityId.value) return
|
||||
auth.switchIdentity(identityId)
|
||||
store.clearActiveSelection()
|
||||
title.value = ''
|
||||
previewContent.value = ''
|
||||
|
||||
if (identityId === '__all__') return
|
||||
|
||||
const identity = auth.identities.value.find((i) => i.id === identityId)
|
||||
if (identity?.keyPair && identity?.aesKey) {
|
||||
await store.ensureNotesLoaded(identity.keyPair, identity.aesKey)
|
||||
ensureSSEConnected(identity.keyPair)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateNote() {
|
||||
const kp = auth.keyPair.value ?? auth.primaryIdentity.value?.keyPair
|
||||
const ak = auth.aesKey.value ?? auth.primaryIdentity.value?.aesKey
|
||||
if (!kp || !ak) return
|
||||
const note = await store.createNote(kp, ak, 'Untitled', '')
|
||||
store.selectNote(note.id)
|
||||
title.value = note.title
|
||||
sheetOpen.value = false
|
||||
}
|
||||
|
||||
async function handleSelectNote(id: string) {
|
||||
store.selectNote(id)
|
||||
const note = store.notes.value.find((n) => n.id === id)
|
||||
if (note) {
|
||||
title.value = note.title
|
||||
previewContent.value = note.content
|
||||
}
|
||||
sheetOpen.value = false
|
||||
}
|
||||
|
||||
async function handleDeleteNote(id: string) {
|
||||
const kp = auth.keyPair.value ?? auth.primaryIdentity.value?.keyPair
|
||||
if (!kp) return
|
||||
await store.deleteNote(kp, id)
|
||||
if (store.selectedNoteId.value) {
|
||||
const current = store.notes.value.find((n) => n.id === store.selectedNoteId.value)
|
||||
title.value = current?.title ?? ''
|
||||
} else {
|
||||
title.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateContent(content: string) {
|
||||
const kp = auth.keyPair.value ?? auth.primaryIdentity.value?.keyPair
|
||||
const ak = auth.aesKey.value ?? auth.primaryIdentity.value?.aesKey
|
||||
if (!kp || !ak || !store.selectedNoteId.value) return
|
||||
await store.updateNote(kp, ak, store.selectedNoteId.value, title.value, content)
|
||||
}
|
||||
|
||||
let titleDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
async function onTitleChange() {
|
||||
if (titleDebounce) clearTimeout(titleDebounce)
|
||||
titleDebounce = setTimeout(async () => {
|
||||
const kp = auth.keyPair.value ?? auth.primaryIdentity.value?.keyPair
|
||||
const ak = auth.aesKey.value ?? auth.primaryIdentity.value?.aesKey
|
||||
if (!kp || !ak || !store.selectedNoteId.value) return
|
||||
await store.updateNote(kp, ak, store.selectedNoteId.value, title.value, previewContent.value)
|
||||
}, 800)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => auth.identities.value.map((i) => i.id),
|
||||
() => connectAllSSE()
|
||||
)
|
||||
|
||||
watch(selectedNote, (note) => {
|
||||
if (note) {
|
||||
title.value = note.title
|
||||
previewContent.value = note.content
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectAllSSE()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-background">
|
||||
<header class="md:hidden flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-md bg-primary">
|
||||
<Shield class="h-3 w-3 text-primary-foreground" />
|
||||
</div>
|
||||
<span class="font-semibold text-sm">SyncPad</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon-sm" @click="handleCreateNote">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
<Sheet v-model:open="sheetOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Menu class="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" class="w-72 p-0">
|
||||
<SheetHeader class="px-4 py-3 border-b">
|
||||
<SheetTitle class="text-sm">Notes</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="px-3 pt-2 pb-1 space-y-0.5">
|
||||
<button
|
||||
@click="handleSwitchIdentity('__all__'); sheetOpen = false"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs transition-colors"
|
||||
:class="auth.activeIdentityId.value === '__all__'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'"
|
||||
>
|
||||
<Users class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>All Notes</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="identity in auth.identities.value"
|
||||
:key="identity.id"
|
||||
@click="handleSwitchIdentity(identity.id); sheetOpen = false"
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs transition-colors"
|
||||
:class="identity.id === auth.activeIdentityId.value
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'"
|
||||
>
|
||||
<User v-if="identity.isPrimary" class="h-3.5 w-3.5 shrink-0" />
|
||||
<Users v-else class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ identity.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-3 py-1.5 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start text-muted-foreground"
|
||||
@click="shareDialogOpen = true"
|
||||
>
|
||||
<Share2 class="mr-2 h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="border-t" />
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<NoteList
|
||||
:notes="store.notes.value"
|
||||
:selected-note-id="store.selectedNoteId.value"
|
||||
@select-note="handleSelectNote"
|
||||
@delete-note="handleDeleteNote"
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<AppSidebar
|
||||
class="hidden md:flex"
|
||||
:notes="store.notes.value"
|
||||
:selected-note-id="store.selectedNoteId.value"
|
||||
:identities="auth.identities.value"
|
||||
:active-identity-id="auth.activeIdentityId.value"
|
||||
:contacts="auth.contacts.value"
|
||||
@select-note="handleSelectNote"
|
||||
@create-note="handleCreateNote"
|
||||
@delete-note="handleDeleteNote"
|
||||
@switch-identity="handleSwitchIdentity"
|
||||
/>
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="border-b px-4 py-2 shrink-0 flex items-center gap-3">
|
||||
<Input
|
||||
v-model="title"
|
||||
@input="onTitleChange"
|
||||
class="border-0 text-lg font-semibold px-0 h-10 focus-visible:ring-0 rounded-none flex-1 min-w-0"
|
||||
placeholder="Untitled"
|
||||
:disabled="!store.selectedNoteId.value"
|
||||
/>
|
||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||
<Tabs v-model="desktopMode">
|
||||
<TabsList class="h-8">
|
||||
<TabsTrigger value="edit" class="text-xs px-2.5 gap-1.5">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="split" class="text-xs px-2.5 gap-1.5">
|
||||
<Columns2 class="h-3.5 w-3.5" />
|
||||
Split
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" class="text-xs px-2.5 gap-1.5">
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button variant="ghost" size="icon-sm" @click="mdHelpOpen = true">
|
||||
<HelpCircle class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex flex-1 overflow-hidden">
|
||||
<div v-show="desktopMode !== 'preview'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<NoteEditor
|
||||
:note="selectedNote"
|
||||
:is-loading="store.isLoading.value"
|
||||
@update="handleUpdateContent"
|
||||
@content-change="handleContentChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="desktopMode === 'split'"
|
||||
class="w-px bg-border"
|
||||
/>
|
||||
<div v-show="desktopMode !== 'edit'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<NotePreview :content="previewContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs v-model="mobileTab" class="md:hidden flex-1 flex flex-col overflow-hidden">
|
||||
<div class="border-b px-2 shrink-0">
|
||||
<TabsList class="w-full justify-start bg-transparent" variant="line">
|
||||
<TabsTrigger value="edit" class="text-xs">Edit</TabsTrigger>
|
||||
<TabsTrigger value="preview" class="text-xs">Preview</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="edit" class="flex-1 flex flex-col overflow-hidden mt-0">
|
||||
<NoteEditor
|
||||
:note="selectedNote"
|
||||
:is-loading="store.isLoading.value"
|
||||
@update="handleUpdateContent"
|
||||
@content-change="handleContentChange"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview" class="flex-1 flex flex-col overflow-hidden mt-0">
|
||||
<NotePreview :content="previewContent" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MdHelpDialog v-model:open="mdHelpOpen" />
|
||||
|
||||
<ShareDialog
|
||||
v-model:open="shareDialogOpen"
|
||||
:identities="auth.identities.value"
|
||||
@share-complete="shareDialogOpen = false"
|
||||
/>
|
||||
</template>
|
||||
36
www/src/views/SetupView.vue
Normal file
36
www/src/views/SetupView.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import KeySetup from '@/components/auth/KeySetup.vue'
|
||||
import { Shield } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
async function handleSetupComplete() {
|
||||
router.replace('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
<div class="w-full max-w-lg space-y-8">
|
||||
<div class="text-center space-y-3">
|
||||
<div class="inline-flex items-center justify-center h-14 w-14 rounded-2xl bg-primary/10 mb-2">
|
||||
<Shield class="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">SyncPad</h1>
|
||||
<p class="text-muted-foreground max-w-sm mx-auto leading-relaxed">
|
||||
A private, encrypted note-taking app. Your notes are end-to-end encrypted.
|
||||
Only you can read them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<KeySetup @setup-complete="handleSetupComplete" />
|
||||
|
||||
<p class="text-xs text-center text-muted-foreground">
|
||||
End-to-end encrypted with Ed25519 + AES-256-GCM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
www/tsconfig.app.json
Normal file
19
www/tsconfig.app.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
14
www/tsconfig.json
Normal file
14
www/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0"
|
||||
}
|
||||
}
|
||||
24
www/tsconfig.node.json
Normal file
24
www/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
68
www/vite.config.ts
Normal file
68
www/vite.config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg'],
|
||||
manifest: {
|
||||
id: '/',
|
||||
name: 'SyncPad',
|
||||
short_name: 'SyncPad',
|
||||
description: 'Private, end-to-end encrypted note-taking app',
|
||||
theme_color: '#0a0a0a',
|
||||
background_color: '#0a0a0a',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-192x192-maskable.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512-maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2}'],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
suppressWarnings: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['syncpad.harvmaster.com', 'localhost'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user