Initial Commit

This commit is contained in:
2026-05-02 05:49:09 +00:00
commit fbe5535087
117 changed files with 18732 additions and 0 deletions

24
www/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

48
www/package.json Normal file
View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
www/public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
www/public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

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

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { TooltipProvider } from '@/components/ui/tooltip'
</script>
<template>
<TooltipProvider>
<router-view />
</TooltipProvider>
</template>

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

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

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

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

View 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: '![alt](url)', 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>

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

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

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

View 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 &amp; 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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

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

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

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

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

View File

@@ -0,0 +1,2 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'

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

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export { default as Textarea } from './Textarea.vue'

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

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

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

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

View 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
View 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)}&timestamp=${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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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'],
},
})