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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
docker-compose.yml
dist
dev-dist
*.db
*.db*
.vscode

2004
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
api/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "api",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"better-sqlite3": "^12.9.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"tweetnacl": "^1.0.3",
"uuid": "^14.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
}

17
api/src/config.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface AppConfig {
port: number;
dbPath: string;
timestampWindowMs: number;
nonceCleanupIntervalMs: number;
corsOrigin: string;
}
export function createConfig(): AppConfig {
return {
port: parseInt(process.env.PORT || '3000', 10),
dbPath: process.env.DB_PATH || './syncpad.db',
timestampWindowMs: 30000,
nonceCleanupIntervalMs: 60000,
corsOrigin: process.env.CORS_ORIGIN || '*',
};
}

23
api/src/container.ts Normal file
View File

@@ -0,0 +1,23 @@
type Factory<T> = (c: Container) => T;
export class Container {
private registry = new Map<string, Factory<any>>();
private instances = new Map<string, any>();
register<T>(key: string, factory: Factory<T>): void {
this.registry.set(key, factory);
}
resolve<T>(key: string): T {
if (this.instances.has(key)) {
return this.instances.get(key) as T;
}
const factory = this.registry.get(key);
if (!factory) {
throw new Error(`No registration found for "${key}"`);
}
const instance = factory(this);
this.instances.set(key, instance);
return instance as T;
}
}

49
api/src/database.ts Normal file
View File

@@ -0,0 +1,49 @@
import Database from 'better-sqlite3';
import { AppConfig } from './config';
export function createDatabase(config: AppConfig): Database.Database {
const db = new Database(config.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
return db;
}
export function runMigrations(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
encrypted_content TEXT NOT NULL,
encrypted_title TEXT NOT NULL,
iv TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_public_key ON notes(public_key);
CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes(updated_at);
CREATE TABLE IF NOT EXISTS nonces (
nonce TEXT NOT NULL,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (nonce, public_key, timestamp)
);
CREATE INDEX IF NOT EXISTS idx_nonces_created_at ON nonces(created_at);
CREATE TABLE IF NOT EXISTS invitations (
id TEXT PRIMARY KEY,
from_public_key TEXT NOT NULL,
to_public_key TEXT NOT NULL,
encrypted_group_key TEXT NOT NULL,
iv TEXT NOT NULL,
note_id TEXT,
note_title TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invitations_to ON invitations(to_public_key);
`);
}

63
api/src/index.ts Normal file
View File

@@ -0,0 +1,63 @@
import express from 'express';
import cors from 'cors';
import { createConfig } from './config';
import { Container } from './container';
import { createDatabase, runMigrations } from './database';
import { NoteRepository } from './repositories/noteRepository';
import { NonceRepository } from './repositories/nonceRepository';
import { InvitationRepository } from './repositories/invitationRepository';
import { NonceService } from './services/nonceService';
import { AuthService } from './services/authService';
import { NoteService } from './services/noteService';
import { SSEService } from './services/sseService';
import { createNotesRouter } from './routes/notes';
import { createSSERouter } from './routes/sse';
import { createInvitationsRouter } from './routes/invitations';
const config = createConfig();
const db = createDatabase(config);
runMigrations(db);
const container = new Container();
container.register('config', () => config);
container.register('db', () => db);
container.register('noteRepository', (c) => new NoteRepository(c.resolve('db')));
container.register('nonceRepository', (c) => new NonceRepository(c.resolve('db')));
container.register('invitationRepository', (c) => new InvitationRepository(c.resolve('db')));
container.register('nonceService', (c) => new NonceService(c.resolve('nonceRepository'), c.resolve('config')));
container.register('authService', (c) => new AuthService(c.resolve('nonceService'), c.resolve('config')));
container.register('noteService', (c) => new NoteService(c.resolve('noteRepository')));
container.register('sseService', () => new SSEService());
const nonceService = container.resolve<NonceService>('nonceService');
nonceService.startCleanupTimer();
const app = express();
app.use(cors({ origin: config.corsOrigin }));
app.use(express.json({ verify: (_req, _res, buf) => { (buf as any).rawBody = buf; } }));
app.use('/api/notes/sync', createSSERouter(
container.resolve('sseService'),
container.resolve('authService')
));
app.use('/api/notes', createNotesRouter(
container.resolve('noteService'),
container.resolve('sseService'),
container.resolve('authService')
));
app.use('/api/invitations', createInvitationsRouter(
container.resolve('invitationRepository'),
container.resolve('sseService'),
container.resolve('authService')
));
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.listen(config.port, () => {
console.log(`SyncPad API listening on port ${config.port}`);
});
export { app };

View File

@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/authService';
declare global {
namespace Express {
interface Request {
publicKey?: string;
}
}
}
export function authMiddleware(authService: AuthService) {
return (req: Request, res: Response, next: NextFunction): void => {
const publicKey = req.headers['x-publickey'] as string | undefined;
const signature = req.headers['x-signature'] as string | undefined;
const nonce = req.headers['x-nonce'] as string | undefined;
const timestampStr = req.headers['x-timestamp'] as string | undefined;
if (!publicKey || !signature || !nonce || !timestampStr) {
res.status(401).json({ error: 'Missing authentication headers' });
return;
}
const timestamp = parseInt(timestampStr, 10);
if (isNaN(timestamp)) {
res.status(401).json({ error: 'Invalid timestamp' });
return;
}
const body = ['GET', 'DELETE'].includes(req.method) ? '' : JSON.stringify(req.body);
const result = authService.verifyRequest(publicKey, signature, timestamp, nonce, body);
if (!result.valid) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
req.publicKey = publicKey;
next();
};
}

View File

@@ -0,0 +1,37 @@
import Database from 'better-sqlite3'
import { Invitation } from '../types'
import { v4 as uuidv4 } from 'uuid'
export class InvitationRepository {
constructor(private db: Database.Database) {}
findByToPublicKey(publicKey: string): Invitation[] {
return this.db.prepare(
'SELECT * FROM invitations WHERE to_public_key = ? ORDER BY created_at DESC'
).all(publicKey) as Invitation[]
}
create(
fromPublicKey: string,
toPublicKey: string,
encryptedGroupKey: string,
iv: string,
noteId?: string,
noteTitle?: string
): Invitation {
const id = uuidv4()
const createdAt = Date.now()
this.db.prepare(
`INSERT INTO invitations (id, from_public_key, to_public_key, encrypted_group_key, iv, note_id, note_title, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, fromPublicKey, toPublicKey, encryptedGroupKey, iv, noteId ?? null, noteTitle ?? null, createdAt)
return { id, from_public_key: fromPublicKey, to_public_key: toPublicKey, encrypted_group_key: encryptedGroupKey, iv, note_id: noteId, note_title: noteTitle, created_at: createdAt }
}
delete(id: string, publicKey: string): boolean {
const result = this.db.prepare(
'DELETE FROM invitations WHERE id = ? AND to_public_key = ?'
).run(id, publicKey)
return result.changes > 0
}
}

View File

@@ -0,0 +1,25 @@
import Database from 'better-sqlite3';
export class NonceRepository {
constructor(private db: Database.Database) {}
exists(nonce: string, publicKey: string, timestamp: number): boolean {
const row = this.db.prepare(
'SELECT 1 FROM nonces WHERE nonce = ? AND public_key = ? AND timestamp = ?'
).get(nonce, publicKey, timestamp);
return !!row;
}
create(nonce: string, publicKey: string, timestamp: number): void {
this.db.prepare(
'INSERT INTO nonces (nonce, public_key, timestamp, created_at) VALUES (?, ?, ?, ?)'
).run(nonce, publicKey, timestamp, Date.now());
}
deleteOlderThan(createdAt: number): number {
const result = this.db.prepare(
'DELETE FROM nonces WHERE created_at < ?'
).run(createdAt);
return result.changes;
}
}

View File

@@ -0,0 +1,46 @@
import Database from 'better-sqlite3';
import { Note, CreateNoteRequest, UpdateNoteRequest } from '../types';
export class NoteRepository {
constructor(private db: Database.Database) {}
findByPublicKey(publicKey: string): Note[] {
return this.db.prepare(
'SELECT * FROM notes WHERE public_key = ? ORDER BY updated_at DESC'
).all(publicKey) as Note[];
}
findById(id: string): Note | undefined {
return this.db.prepare(
'SELECT * FROM notes WHERE id = ?'
).get(id) as Note | undefined;
}
findByIdAndPublicKey(id: string, publicKey: string): Note | undefined {
return this.db.prepare(
'SELECT * FROM notes WHERE id = ? AND public_key = ?'
).get(id, publicKey) as Note | undefined;
}
create(note: Note): void {
this.db.prepare(
'INSERT INTO notes (id, public_key, encrypted_content, encrypted_title, iv, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(note.id, note.public_key, note.encrypted_content, note.encrypted_title, note.iv, note.created_at, note.updated_at);
}
update(id: string, publicKey: string, data: UpdateNoteRequest, updatedAt: number): Note | undefined {
const result = this.db.prepare(
'UPDATE notes SET encrypted_content = ?, encrypted_title = ?, iv = ?, updated_at = ? WHERE id = ? AND public_key = ?'
).run(data.encrypted_content, data.encrypted_title, data.iv, updatedAt, id, publicKey);
if (result.changes === 0) return undefined;
return this.findById(id);
}
delete(id: string, publicKey: string): boolean {
const result = this.db.prepare(
'DELETE FROM notes WHERE id = ? AND public_key = ?'
).run(id, publicKey);
return result.changes > 0;
}
}

View File

@@ -0,0 +1,55 @@
import { Router, Request, Response } from 'express'
import { InvitationRepository } from '../repositories/invitationRepository'
import { SSEService } from '../services/sseService'
import { AuthService } from '../services/authService'
import { authMiddleware } from '../middleware/auth'
import { CreateInvitationRequest } from '../types'
export function createInvitationsRouter(
invitationRepo: InvitationRepository,
sseService: SSEService,
authService: AuthService
): Router {
const router = Router()
const auth = authMiddleware(authService)
router.get('/', auth, (req: Request, res: Response) => {
const invitations = invitationRepo.findByToPublicKey(req.publicKey!)
res.json({ invitations })
})
router.post('/', auth, (req: Request, res: Response) => {
const data = req.body as CreateInvitationRequest
if (!data.toPublicKey) {
res.status(400).json({ error: 'Missing toPublicKey' })
return
}
const invitation = invitationRepo.create(
req.publicKey!,
data.toPublicKey,
data.encryptedGroupKey ?? '',
data.iv ?? '',
data.noteId,
data.noteTitle
)
sseService.broadcastToPublicKey(data.toPublicKey, {
type: 'invitation_received',
data: invitation,
})
res.status(201).json({ invitation })
})
router.delete('/:id', auth, (req: Request, res: Response) => {
const deleted = invitationRepo.delete(req.params.id as string, req.publicKey!)
if (!deleted) {
res.status(404).json({ error: 'Invitation not found' })
return
}
res.json({ success: true })
})
return router
}

68
api/src/routes/notes.ts Normal file
View File

@@ -0,0 +1,68 @@
import { Router, Request, Response } from 'express';
import { NoteService } from '../services/noteService';
import { SSEService } from '../services/sseService';
import { authMiddleware } from '../middleware/auth';
import { AuthService } from '../services/authService';
import { CreateNoteRequest, UpdateNoteRequest } from '../types';
export function createNotesRouter(
noteService: NoteService,
sseService: SSEService,
authService: AuthService
): Router {
const router = Router();
const auth = authMiddleware(authService);
router.get('/', auth, (req: Request, res: Response) => {
const notes = noteService.getNotes(req.publicKey!);
res.json({ notes });
});
router.post('/', auth, (req: Request, res: Response) => {
const data = req.body as CreateNoteRequest;
if (!data.id || !data.encrypted_content || !data.encrypted_title || !data.iv) {
res.status(400).json({ error: 'Missing required fields' });
return;
}
const note = noteService.createNote(req.publicKey!, data);
if (!note) {
res.status(409).json({ error: 'Note with this ID already exists' });
return;
}
sseService.broadcastToPublicKey(req.publicKey!, { type: 'note_created', data: note });
res.status(201).json({ note });
});
router.put('/:id', auth, (req: Request, res: Response) => {
const data = req.body as UpdateNoteRequest;
if (!data.encrypted_content || !data.encrypted_title || !data.iv) {
res.status(400).json({ error: 'Missing required fields' });
return;
}
const note = noteService.updateNote(req.params.id as string, req.publicKey!, data);
if (!note) {
res.status(404).json({ error: 'Note not found' });
return;
}
sseService.broadcastToPublicKey(req.publicKey!, { type: 'note_updated', data: note });
res.json({ note });
});
router.delete('/:id', auth, (req: Request, res: Response) => {
const id = req.params.id as string;
const deleted = noteService.deleteNote(id, req.publicKey!);
if (!deleted) {
res.status(404).json({ error: 'Note not found' });
return;
}
sseService.broadcastToPublicKey(req.publicKey!, { type: 'note_deleted', data: { id } });
res.json({ success: true });
});
return router;
}

39
api/src/routes/sse.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Router, Request, Response } from 'express';
import { SSEService } from '../services/sseService';
import { AuthService } from '../services/authService';
export function createSSERouter(
sseService: SSEService,
authService: AuthService
): Router {
const router = Router();
router.get('/', (req: Request, res: Response) => {
const publicKey = req.query.publickey as string | undefined;
const signature = req.query.signature as string | undefined;
const nonce = req.query.nonce as string | undefined;
const timestampStr = req.query.timestamp as string | undefined;
if (!publicKey || !signature || !nonce || !timestampStr) {
res.status(401).json({ error: 'Missing authentication parameters' });
return;
}
const timestamp = parseInt(timestampStr, 10);
if (isNaN(timestamp)) {
res.status(401).json({ error: 'Invalid timestamp' });
return;
}
const result = authService.verifyRequest(publicKey, signature, timestamp, nonce, '');
if (!result.valid) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
sseService.addClient(publicKey, res);
});
return router;
}

View File

@@ -0,0 +1,54 @@
import nacl from 'tweetnacl';
import { NonceService } from './nonceService';
import { AppConfig } from '../config';
export class AuthService {
constructor(
private nonceService: NonceService,
private config: AppConfig
) {}
verifyRequest(
publicKeyB64: string,
signatureB64: string,
timestamp: number,
nonce: string,
body: string
): { valid: boolean; error?: string } {
const now = Date.now();
if (Math.abs(now - timestamp) > this.config.timestampWindowMs) {
return { valid: false, error: 'Timestamp expired or invalid' };
}
if (!this.nonceService.validateAndStore(nonce, publicKeyB64, timestamp)) {
return { valid: false, error: 'Nonce already used' };
}
let publicKey: Uint8Array;
let signature: Uint8Array;
try {
publicKey = base64ToUint8Array(publicKeyB64);
signature = base64ToUint8Array(signatureB64);
} catch {
return { valid: false, error: 'Invalid key format' };
}
if (publicKey.length !== nacl.sign.publicKeyLength) {
return { valid: false, error: 'Invalid public key length' };
}
const message = `${timestamp}:${body}`;
const messageBytes = new TextEncoder().encode(message);
const isValid = nacl.sign.detached.verify(messageBytes, signature, publicKey);
if (!isValid) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
}
function base64ToUint8Array(b64: string): Uint8Array {
return Uint8Array.from(Buffer.from(b64, 'base64'));
}

View File

@@ -0,0 +1,28 @@
import { NonceRepository } from '../repositories/nonceRepository';
import { AppConfig } from '../config';
export class NonceService {
constructor(
private nonceRepo: NonceRepository,
private config: AppConfig
) {}
validateAndStore(nonce: string, publicKey: string, timestamp: number): boolean {
if (this.nonceRepo.exists(nonce, publicKey, timestamp)) {
return false;
}
this.nonceRepo.create(nonce, publicKey, timestamp);
return true;
}
cleanupOldNonces(): number {
const cutoff = Date.now() - this.config.timestampWindowMs * 2;
return this.nonceRepo.deleteOlderThan(cutoff);
}
startCleanupTimer(): NodeJS.Timeout {
return setInterval(() => {
this.cleanupOldNonces();
}, this.config.nonceCleanupIntervalMs);
}
}

View File

@@ -0,0 +1,40 @@
import { NoteRepository } from '../repositories/noteRepository';
import { Note, CreateNoteRequest, UpdateNoteRequest } from '../types';
export class NoteService {
constructor(private noteRepo: NoteRepository) {}
getNotes(publicKey: string): Note[] {
return this.noteRepo.findByPublicKey(publicKey);
}
getNote(id: string, publicKey: string): Note | undefined {
return this.noteRepo.findByIdAndPublicKey(id, publicKey);
}
createNote(publicKey: string, data: CreateNoteRequest): Note | null {
const existing = this.noteRepo.findById(data.id);
if (existing) return null;
const now = Date.now();
const note: Note = {
id: data.id,
public_key: publicKey,
encrypted_content: data.encrypted_content,
encrypted_title: data.encrypted_title,
iv: data.iv,
created_at: now,
updated_at: now,
};
this.noteRepo.create(note);
return note;
}
updateNote(id: string, publicKey: string, data: UpdateNoteRequest): Note | undefined {
return this.noteRepo.update(id, publicKey, data, Date.now());
}
deleteNote(id: string, publicKey: string): boolean {
return this.noteRepo.delete(id, publicKey);
}
}

View File

@@ -0,0 +1,47 @@
import { Response } from 'express';
import { SSEClient, SSEMessage } from '../types';
import { v4 as uuidv4 } from 'uuid';
export class SSEService {
private clients: Map<string, SSEClient> = new Map();
addClient(publicKey: string, res: Response): string {
const id = uuidv4();
const client: SSEClient = { id, public_key: publicKey, res };
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write(`data: ${JSON.stringify({ type: 'connected', clientId: id })}\n\n`);
res.on('close', () => {
this.clients.delete(id);
});
this.clients.set(id, client);
return id;
}
removeClient(id: string): void {
const client = this.clients.get(id);
if (client) {
client.res.end();
this.clients.delete(id);
}
}
broadcastToPublicKey(publicKey: string, message: SSEMessage): void {
for (const client of this.clients.values()) {
if (client.public_key === publicKey) {
try {
client.res.write(`event: ${message.type}\ndata: ${JSON.stringify(message.data)}\n\n`);
} catch {
this.clients.delete(client.id);
}
}
}
}
}

52
api/src/types.ts Normal file
View File

@@ -0,0 +1,52 @@
export interface Note {
id: string;
public_key: string;
encrypted_content: string;
encrypted_title: string;
iv: string;
created_at: number;
updated_at: number;
}
export interface CreateNoteRequest {
id: string;
encrypted_content: string;
encrypted_title: string;
iv: string;
}
export interface UpdateNoteRequest {
encrypted_content: string;
encrypted_title: string;
iv: string;
}
export interface SSEClient {
id: string;
public_key: string;
res: import('express').Response;
}
export interface SSEMessage {
type: 'note_created' | 'note_updated' | 'note_deleted' | 'invitation_received';
data: Note | { id: string } | Invitation;
}
export interface Invitation {
id: string;
from_public_key: string;
to_public_key: string;
encrypted_group_key: string;
iv: string;
note_id?: string;
note_title?: string;
created_at: number;
}
export interface CreateInvitationRequest {
toPublicKey: string;
encryptedGroupKey?: string;
iv?: string;
noteId?: string;
noteTitle?: string;
}

19
api/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

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

Some files were not shown because too many files have changed in this diff Show More