Initial Commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
docker-compose.yml
|
||||||
|
dist
|
||||||
|
dev-dist
|
||||||
|
*.db
|
||||||
|
*.db*
|
||||||
|
.vscode
|
||||||
2004
api/package-lock.json
generated
Normal file
2004
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
api/package.json
Normal file
31
api/package.json
Normal 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
17
api/src/config.ts
Normal 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
23
api/src/container.ts
Normal 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
49
api/src/database.ts
Normal 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
63
api/src/index.ts
Normal 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 };
|
||||||
42
api/src/middleware/auth.ts
Normal file
42
api/src/middleware/auth.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
37
api/src/repositories/invitationRepository.ts
Normal file
37
api/src/repositories/invitationRepository.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
25
api/src/repositories/nonceRepository.ts
Normal file
25
api/src/repositories/nonceRepository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
api/src/repositories/noteRepository.ts
Normal file
46
api/src/repositories/noteRepository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
api/src/routes/invitations.ts
Normal file
55
api/src/routes/invitations.ts
Normal 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
68
api/src/routes/notes.ts
Normal 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
39
api/src/routes/sse.ts
Normal 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;
|
||||||
|
}
|
||||||
54
api/src/services/authService.ts
Normal file
54
api/src/services/authService.ts
Normal 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'));
|
||||||
|
}
|
||||||
28
api/src/services/nonceService.ts
Normal file
28
api/src/services/nonceService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
api/src/services/noteService.ts
Normal file
40
api/src/services/noteService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
api/src/services/sseService.ts
Normal file
47
api/src/services/sseService.ts
Normal 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
52
api/src/types.ts
Normal 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
19
api/tsconfig.json
Normal 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
24
www/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
5
www/README.md
Normal file
5
www/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
12
www/capacitor.config.ts
Normal file
12
www/capacitor.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli'
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.syncpad.app',
|
||||||
|
appName: 'SyncPad',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
androidScheme: 'https',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
25
www/components.json
Normal file
25
www/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "reka-nova",
|
||||||
|
"font": "geist-sans",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/style.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
13
www/index.html
Normal file
13
www/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SyncPad</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11387
www/package-lock.json
generated
Normal file
11387
www/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
www/package.json
Normal file
48
www/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "sync-pad",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "node scripts/generate-icons.mjs && vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"cap:sync": "npx cap sync",
|
||||||
|
"cap:open:android": "npx cap open android",
|
||||||
|
"cap:open:ios": "npx cap open ios",
|
||||||
|
"cap:add:android": "npx cap add android",
|
||||||
|
"cap:add:ios": "npx cap add ios"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/cli": "^8.3.1",
|
||||||
|
"@capacitor/core": "^8.3.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@vueuse/core": "^14.2.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
|
"marked": "^18.0.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"reka-ui": "^2.9.6",
|
||||||
|
"shadcn-vue": "^2.6.2",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.10",
|
||||||
|
"vue-tsc": "^3.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
www/public/favicon.svg
Normal file
1
www/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
www/public/icons.svg
Normal file
24
www/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
www/public/pwa-192x192-maskable.png
Normal file
BIN
www/public/pwa-192x192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
www/public/pwa-192x192.png
Normal file
BIN
www/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
www/public/pwa-512x512-maskable.png
Normal file
BIN
www/public/pwa-512x512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
www/public/pwa-512x512.png
Normal file
BIN
www/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
33
www/scripts/generate-icons.mjs
Normal file
33
www/scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import sharp from 'sharp'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
|
||||||
|
const svg = readFileSync('public/favicon.svg')
|
||||||
|
|
||||||
|
const sizes = [192, 512]
|
||||||
|
|
||||||
|
for (const size of sizes) {
|
||||||
|
await sharp(svg)
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toFile(`public/pwa-${size}x${size}.png`)
|
||||||
|
console.log(`Generated pwa-${size}x${size}.png`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskablePad = 0.2
|
||||||
|
for (const size of sizes) {
|
||||||
|
const padPx = Math.round(size * maskablePad)
|
||||||
|
await sharp(svg)
|
||||||
|
.resize(size - padPx * 2, size - padPx * 2)
|
||||||
|
.extend({
|
||||||
|
top: padPx,
|
||||||
|
bottom: padPx,
|
||||||
|
left: padPx,
|
||||||
|
right: padPx,
|
||||||
|
background: { r: 10, g: 10, b: 10, alpha: 1 },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toFile(`public/pwa-${size}x${size}-maskable.png`)
|
||||||
|
console.log(`Generated pwa-${size}x${size}-maskable.png`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.')
|
||||||
9
www/src/App.vue
Normal file
9
www/src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipProvider>
|
||||||
|
<router-view />
|
||||||
|
</TooltipProvider>
|
||||||
|
</template>
|
||||||
199
www/src/components/auth/KeySetup.vue
Normal file
199
www/src/components/auth/KeySetup.vue
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { Key, Copy, Check, Shield, Sparkles, ArrowRight, QrCode, Scan } from 'lucide-vue-next'
|
||||||
|
import QrCodeDisplay from '@/components/auth/QrCodeDisplay.vue'
|
||||||
|
import QrCodeScanner from '@/components/auth/QrCodeScanner.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'setup-complete'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const activeTab = ref('generate')
|
||||||
|
const generatedSecretKey = ref('')
|
||||||
|
const importedSecretKey = ref('')
|
||||||
|
const importError = ref('')
|
||||||
|
const copied = ref(false)
|
||||||
|
const qrDisplayOpen = ref(false)
|
||||||
|
const qrScanOpen = ref(false)
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
const pair = auth.generateKeyPair()
|
||||||
|
generatedSecretKey.value = pair.secretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
importError.value = ''
|
||||||
|
const trimmed = importedSecretKey.value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
importError.value = 'Please enter a secret key.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pair = auth.importKeyPair(trimmed)
|
||||||
|
await auth.setupKeyPair(pair)
|
||||||
|
emit('setup-complete')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Key import failed:', e)
|
||||||
|
importError.value = 'Invalid secret key. Please check and try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContinue() {
|
||||||
|
if (!generatedSecretKey.value) return
|
||||||
|
try {
|
||||||
|
const pair = auth.importKeyPair(generatedSecretKey.value)
|
||||||
|
await auth.setupKeyPair(pair)
|
||||||
|
emit('setup-complete')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Key setup failed:', e)
|
||||||
|
importError.value = 'Failed to set up keys.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQrScan(value: string) {
|
||||||
|
importedSecretKey.value = value
|
||||||
|
activeTab.value = 'import'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySecretKey() {
|
||||||
|
if (!generatedSecretKey.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(generatedSecretKey.value)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = generatedSecretKey.value
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="w-full max-w-lg mx-auto">
|
||||||
|
<CardHeader class="text-center">
|
||||||
|
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||||
|
<Shield class="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle class="text-2xl">Set Up SyncPad</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate a new encryption key pair or import an existing one.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="generate">
|
||||||
|
<Sparkles class="mr-2 h-4 w-4" />
|
||||||
|
Generate New Key
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="import">
|
||||||
|
<Key class="mr-2 h-4 w-4" />
|
||||||
|
Import Existing Key
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="generate" class="mt-4 space-y-4">
|
||||||
|
<div v-if="!generatedSecretKey" class="flex flex-col items-center gap-3 py-6">
|
||||||
|
<Shield class="h-16 w-16 text-muted-foreground/40" />
|
||||||
|
<p class="text-sm text-muted-foreground text-center max-w-xs">
|
||||||
|
Generate a new Ed25519 key pair for end-to-end encryption. Your secret key is your
|
||||||
|
only way to access your notes.
|
||||||
|
</p>
|
||||||
|
<Button @click="handleGenerate" class="mt-2">
|
||||||
|
<Sparkles class="mr-2 h-4 w-4" />
|
||||||
|
Generate Key Pair
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
|
||||||
|
<p class="text-sm font-medium text-amber-600 dark:text-amber-400 mb-2">
|
||||||
|
Save your secret key! It cannot be recovered.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Store it somewhere safe. You will need it to access your notes from other devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Your Secret Key</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="generatedSecretKey"
|
||||||
|
readonly
|
||||||
|
class="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" @click="copySecretKey">
|
||||||
|
<Check v-if="copied" class="h-4 w-4 text-green-500" />
|
||||||
|
<Copy v-else class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button @click="handleContinue" class="flex-1">
|
||||||
|
Continue to SyncPad
|
||||||
|
<ArrowRight class="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" @click="qrDisplayOpen = true">
|
||||||
|
<QrCode class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="importError" class="text-sm text-destructive text-center">{{ importError }}</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="import" class="mt-4 space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="import-key">Secret Key (Base64)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="import-key"
|
||||||
|
v-model="importedSecretKey"
|
||||||
|
placeholder="Paste your secret key here..."
|
||||||
|
class="font-mono text-xs min-h-[100px]"
|
||||||
|
/>
|
||||||
|
<p v-if="importError" class="text-sm text-destructive">{{ importError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button @click="handleImport" class="flex-1" :disabled="!importedSecretKey.trim()">
|
||||||
|
<Key class="mr-2 h-4 w-4" />
|
||||||
|
Import Key
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" @click="qrScanOpen = true">
|
||||||
|
<Scan class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<QrCodeDisplay
|
||||||
|
v-model:open="qrDisplayOpen"
|
||||||
|
:value="generatedSecretKey"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QrCodeScanner
|
||||||
|
v-model:open="qrScanOpen"
|
||||||
|
@scan-success="handleQrScan"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
59
www/src/components/auth/QrCodeDisplay.vue
Normal file
59
www/src/components/auth/QrCodeDisplay.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
value: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const qrSvg = ref('')
|
||||||
|
|
||||||
|
watch(() => props.value, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
qrSvg.value = await QRCode.toString(val, {
|
||||||
|
type: 'svg',
|
||||||
|
width: 300,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000', light: '#fff' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Export Secret Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Scan this QR code on another device to import your key.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<div
|
||||||
|
v-if="qrSvg"
|
||||||
|
class="bg-white p-4 rounded-xl"
|
||||||
|
v-html="qrSvg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="outline" @click="emit('update:open', false)">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
139
www/src/components/auth/QrCodeScanner.vue
Normal file
139
www/src/components/auth/QrCodeScanner.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUnmounted, nextTick } from 'vue'
|
||||||
|
import jsQR from 'jsqr'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Camera, AlertCircle } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
(e: 'scan-success', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||||
|
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
const scanning = ref(false)
|
||||||
|
let stream: MediaStream | null = null
|
||||||
|
let animationId: number | null = null
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
error.value = ''
|
||||||
|
scanning.value = true
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment' },
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
if (videoEl.value) {
|
||||||
|
videoEl.value.srcObject = stream
|
||||||
|
await videoEl.value.play()
|
||||||
|
}
|
||||||
|
scanFrame()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Could not access camera.'
|
||||||
|
scanning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanFrame() {
|
||||||
|
if (!videoEl.value || !canvasEl.value) return
|
||||||
|
const video = videoEl.value
|
||||||
|
const canvas = canvasEl.value
|
||||||
|
if (video.readyState !== video.HAVE_ENOUGH_DATA) {
|
||||||
|
animationId = requestAnimationFrame(scanFrame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||||
|
const code = jsQR(imageData.data, canvas.width, canvas.height)
|
||||||
|
if (code) {
|
||||||
|
stopCamera()
|
||||||
|
emit('scan-success', code.data)
|
||||||
|
emit('update:open', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
animationId = requestAnimationFrame(scanFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId)
|
||||||
|
animationId = null
|
||||||
|
}
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop())
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
scanning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
stopCamera()
|
||||||
|
} else {
|
||||||
|
startCamera()
|
||||||
|
}
|
||||||
|
emit('update:open', open)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopCamera()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="onOpenChange">
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Scan QR Code</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Point your camera at a SyncPad secret key QR code.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div class="relative w-full aspect-square rounded-lg overflow-hidden bg-black">
|
||||||
|
<video
|
||||||
|
ref="videoEl"
|
||||||
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<canvas ref="canvasEl" class="hidden" />
|
||||||
|
<div
|
||||||
|
v-if="!scanning && !error"
|
||||||
|
class="absolute inset-0 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Camera class="h-10 w-10 text-white/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="flex items-center gap-2 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="outline" @click="onOpenChange(false)">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
208
www/src/components/layout/AppSidebar.vue
Normal file
208
www/src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DecryptedNote, Identity, Contact } from '@/types'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import NoteList from '@/components/note/NoteList.vue'
|
||||||
|
import ShareDialog from '@/components/note/ShareDialog.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import {
|
||||||
|
Plus, Settings, Shield, Key, Copy, Check, Trash2, LogOut,
|
||||||
|
User, Users, Share2,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
notes: DecryptedNote[]
|
||||||
|
selectedNoteId: string | null
|
||||||
|
identities: Identity[]
|
||||||
|
activeIdentityId: string
|
||||||
|
contacts: Contact[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select-note', id: string): void
|
||||||
|
(e: 'create-note'): void
|
||||||
|
(e: 'delete-note', id: string): void
|
||||||
|
(e: 'switch-identity', id: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
const copied = ref(false)
|
||||||
|
const confirmReset = ref(false)
|
||||||
|
const shareDialogOpen = ref(false)
|
||||||
|
|
||||||
|
async function copyKey() {
|
||||||
|
const key = auth.primaryIdentity.value?.keyPair.secretKey
|
||||||
|
if (!key) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = key
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
if (!confirmReset.value) {
|
||||||
|
confirmReset.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.clearKeys()
|
||||||
|
settingsOpen.value = false
|
||||||
|
confirmReset.value = false
|
||||||
|
router.push('/setup')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShareComplete() {
|
||||||
|
shareDialogOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="w-64 h-full flex flex-col border-r bg-card shrink-0">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-primary">
|
||||||
|
<Shield class="h-3.5 w-3.5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-sm">SyncPad</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-3 pb-1 space-y-0.5">
|
||||||
|
<button
|
||||||
|
@click="emit('switch-identity', '__all__')"
|
||||||
|
class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs transition-colors"
|
||||||
|
:class="activeIdentityId === '__all__'
|
||||||
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'"
|
||||||
|
>
|
||||||
|
<Users class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>All Notes</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="identity in identities"
|
||||||
|
:key="identity.id"
|
||||||
|
@click="emit('switch-identity', identity.id)"
|
||||||
|
class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs transition-colors"
|
||||||
|
:class="identity.id === activeIdentityId
|
||||||
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'"
|
||||||
|
>
|
||||||
|
<User v-if="identity.isPrimary" class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<Users v-else class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span class="truncate">{{ identity.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="px-3 py-2 space-y-1.5">
|
||||||
|
<Button class="w-full justify-start" size="sm" @click="emit('create-note')">
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
New Note
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="w-full justify-start text-muted-foreground"
|
||||||
|
@click="shareDialogOpen = true"
|
||||||
|
>
|
||||||
|
<Share2 class="mr-2 h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<NoteList
|
||||||
|
:notes="notes"
|
||||||
|
:selected-note-id="selectedNoteId"
|
||||||
|
@select-note="emit('select-note', $event)"
|
||||||
|
@delete-note="emit('delete-note', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="p-2">
|
||||||
|
<Dialog v-model:open="settingsOpen">
|
||||||
|
<DialogTrigger as-child>
|
||||||
|
<Button variant="ghost" size="sm" class="w-full justify-start text-muted-foreground">
|
||||||
|
<Settings class="mr-2 h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Key Information</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Your Ed25519 key pair for authentication and encryption.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Public Key</Label>
|
||||||
|
<Input :model-value="auth.primaryIdentity.value?.keyPair.publicKey ?? ''" readonly class="font-mono text-xs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Secret Key (keep this safe!)</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input :model-value="auth.primaryIdentity.value?.keyPair.secretKey ?? ''" readonly class="font-mono text-xs" />
|
||||||
|
<Button variant="outline" size="icon" @click="copyKey">
|
||||||
|
<Check v-if="copied" class="h-4 w-4 text-green-500" />
|
||||||
|
<Copy v-else class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="flex-col gap-2 sm:flex-col">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
@click="handleReset"
|
||||||
|
>
|
||||||
|
<LogOut v-if="!confirmReset" class="mr-2 h-4 w-4" />
|
||||||
|
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||||
|
{{ confirmReset ? 'Confirm Reset' : 'Reset All Data' }}
|
||||||
|
</Button>
|
||||||
|
<p v-if="confirmReset" class="text-xs text-destructive text-center">
|
||||||
|
This will remove your keys and redirect you to setup.
|
||||||
|
</p>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShareDialog
|
||||||
|
v-model:open="shareDialogOpen"
|
||||||
|
:identities="identities"
|
||||||
|
@share-complete="handleShareComplete"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
105
www/src/components/note/MdHelpDialog.vue
Normal file
105
www/src/components/note/MdHelpDialog.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: 'Headings',
|
||||||
|
items: [
|
||||||
|
{ syntax: '# H1', result: 'Largest heading' },
|
||||||
|
{ syntax: '## H2', result: 'Second largest' },
|
||||||
|
{ syntax: '### H3', result: 'Third largest' },
|
||||||
|
{ syntax: '#### H4', result: 'Fourth' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Text Formatting',
|
||||||
|
items: [
|
||||||
|
{ syntax: '**bold**', result: 'Bold text' },
|
||||||
|
{ syntax: '*italic*', result: 'Italic text' },
|
||||||
|
{ syntax: '~~strikethrough~~', result: 'Strikethrough' },
|
||||||
|
{ syntax: '`code`', result: 'Inline code' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Lists',
|
||||||
|
items: [
|
||||||
|
{ syntax: '- item\n- item\n- item', result: 'Unordered list' },
|
||||||
|
{ syntax: '1. First\n2. Second\n3. Third', result: 'Ordered list' },
|
||||||
|
{ syntax: '- [x] Done\n- [ ] Todo', result: 'Task list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Links & Images',
|
||||||
|
items: [
|
||||||
|
{ syntax: '[text](url)', result: 'Hyperlink' },
|
||||||
|
{ syntax: '', result: 'Image' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Blocks',
|
||||||
|
items: [
|
||||||
|
{ syntax: '> quoted text', result: 'Blockquote' },
|
||||||
|
{ syntax: '```\ncode block\n```', result: 'Fenced code block' },
|
||||||
|
{ syntax: '```js\nconst x = 1\n```', result: 'Code with language' },
|
||||||
|
{ syntax: '---', result: 'Horizontal rule' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tables',
|
||||||
|
items: [
|
||||||
|
{ syntax: '| A | B |\n| --- | --- |\n| 1 | 2 |', result: 'Table' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||||
|
<DialogContent class="sm:max-w-lg max-h-[85vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Markdown Reference</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Quick syntax guide for formatting your notes.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea class="max-h-[60vh] pr-4">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-for="section in sections" :key="section.title">
|
||||||
|
<h4 class="text-sm font-semibold mb-2">{{ section.title }}</h4>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.syntax"
|
||||||
|
class="flex items-start gap-4 text-xs p-2 rounded-md bg-muted/50"
|
||||||
|
>
|
||||||
|
<code class="font-mono text-primary shrink-0 whitespace-pre min-w-[120px] leading-relaxed">{{ item.syntax }}</code>
|
||||||
|
<span class="text-muted-foreground leading-relaxed pt-px">{{ item.result }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<div class="flex justify-end pt-2">
|
||||||
|
<Button variant="outline" @click="emit('update:open', false)">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
81
www/src/components/note/NoteEditor.vue
Normal file
81
www/src/components/note/NoteEditor.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
import type { DecryptedNote } from '@/types'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
note: DecryptedNote | null
|
||||||
|
isLoading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', content: string): void
|
||||||
|
(e: 'content-change', content: string): void
|
||||||
|
(e: 'update-title', title: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const content = ref(props.note?.content ?? '')
|
||||||
|
const lastSaved = ref<string | null>(null)
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(() => props.note, (newNote) => {
|
||||||
|
if (newNote) {
|
||||||
|
content.value = newNote.content
|
||||||
|
lastSaved.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
if (previewTimer) clearTimeout(previewTimer)
|
||||||
|
previewTimer = setTimeout(() => {
|
||||||
|
emit('content-change', content.value)
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (props.note && content.value !== props.note.content) {
|
||||||
|
emit('update', content.value)
|
||||||
|
lastSaved.value = new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
if (previewTimer) clearTimeout(previewTimer)
|
||||||
|
emit('content-change', content.value)
|
||||||
|
if (props.note && content.value !== props.note.content) {
|
||||||
|
emit('update', content.value)
|
||||||
|
lastSaved.value = new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
<div v-if="!note" class="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<p class="text-sm">Select a note to start editing</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<Textarea
|
||||||
|
v-model="content"
|
||||||
|
@input="onInput"
|
||||||
|
@blur="onBlur"
|
||||||
|
class="flex-1 border-0 rounded-none resize-none focus-visible:ring-0 editor-textarea bg-transparent p-5 leading-relaxed"
|
||||||
|
:placeholder="'Start writing...'"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="lastSaved"
|
||||||
|
class="absolute bottom-3 right-3 text-xs text-muted-foreground bg-background/80 px-2 py-0.5 rounded"
|
||||||
|
>
|
||||||
|
Saved {{ lastSaved }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
107
www/src/components/note/NoteList.vue
Normal file
107
www/src/components/note/NoteList.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DecryptedNote } from '@/types'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { FileText, Trash2, MoreHorizontal } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
notes: DecryptedNote[]
|
||||||
|
selectedNoteId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select-note', id: string): void
|
||||||
|
(e: 'delete-note', id: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const contextMenuNoteId = ref<string | null>(null)
|
||||||
|
const contextMenuPos = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
function handleContextMenu(event: MouseEvent, noteId: string) {
|
||||||
|
event.preventDefault()
|
||||||
|
contextMenuNoteId.value = noteId
|
||||||
|
contextMenuPos.value = { x: event.clientX, y: event.clientY }
|
||||||
|
document.addEventListener('click', closeContextMenu, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextMenuNoteId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = now - ts
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 1) return 'Just now'
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days === 1) return 'Yesterday'
|
||||||
|
if (days < 7) return `${days}d ago`
|
||||||
|
return new Date(ts).toLocaleDateString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScrollArea class="flex-1">
|
||||||
|
<div class="space-y-0.5 p-2">
|
||||||
|
<div
|
||||||
|
v-if="notes.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-12 px-4 text-center"
|
||||||
|
>
|
||||||
|
<FileText class="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
No notes yet.
|
||||||
|
<br />
|
||||||
|
Create your first note!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="note in notes"
|
||||||
|
:key="note.id"
|
||||||
|
@click="emit('select-note', note.id)"
|
||||||
|
@contextmenu="handleContextMenu($event, note.id)"
|
||||||
|
class="w-full text-left px-3 py-2.5 rounded-lg transition-colors group"
|
||||||
|
:class="[
|
||||||
|
note.id === selectedNoteId
|
||||||
|
? 'bg-primary/10 text-foreground'
|
||||||
|
: 'hover:bg-muted/60 text-foreground'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="text-sm font-medium truncate flex-1">
|
||||||
|
{{ note.title || 'Untitled' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click.stop="emit('delete-note', note.id)"
|
||||||
|
class="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" class="mt-1 text-[10px] px-1.5 py-0">
|
||||||
|
{{ formatDate(note.updated_at) }}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="contextMenuNoteId"
|
||||||
|
class="fixed z-50 min-w-[140px] rounded-lg border bg-popover p-1 shadow-md animate-fade-in"
|
||||||
|
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="emit('delete-note', contextMenuNoteId); closeContextMenu()"
|
||||||
|
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
29
www/src/components/note/NotePreview.vue
Normal file
29
www/src/components/note/NotePreview.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const html = computed(() => {
|
||||||
|
if (!props.content) return ''
|
||||||
|
return marked.parse(props.content) as string
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScrollArea class="flex-1">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<div v-if="!content" class="flex items-center justify-center h-full text-muted-foreground p-8">
|
||||||
|
<p class="text-sm">Preview will appear here</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="markdown-body p-8 max-w-3xl mx-auto"
|
||||||
|
v-html="html"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</template>
|
||||||
307
www/src/components/note/ShareDialog.vue
Normal file
307
www/src/components/note/ShareDialog.vue
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { Identity } from '@/types'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useNotesStore } from '@/stores/notes'
|
||||||
|
import { encodePublicKeyLink, decodePublicKeyLink } from '@/lib/sharing'
|
||||||
|
import { Copy, Check, QrCode, UserPlus, Users, Link, Pencil, X } from 'lucide-vue-next'
|
||||||
|
import QrCodeScanner from '@/components/auth/QrCodeScanner.vue'
|
||||||
|
import QrCodeDisplay from '@/components/auth/QrCodeDisplay.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
identities: Identity[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:open', value: boolean): void
|
||||||
|
(e: 'share-complete'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const notesStore = useNotesStore()
|
||||||
|
const myPublicKey = ref(auth.primaryIdentity.value?.keyPair.publicKey ?? '')
|
||||||
|
const contactKey = ref('')
|
||||||
|
const contactLabel = ref('')
|
||||||
|
const addContactError = ref('')
|
||||||
|
const copiedLink = ref(false)
|
||||||
|
const copiedKey = ref(false)
|
||||||
|
const qrDisplayOpen = ref(false)
|
||||||
|
const qrScanOpen = ref(false)
|
||||||
|
const editingContact = ref<string | null>(null)
|
||||||
|
const editLabel = ref('')
|
||||||
|
|
||||||
|
const inviteLink = ref('')
|
||||||
|
|
||||||
|
function updateInviteLink() {
|
||||||
|
if (myPublicKey.value) {
|
||||||
|
inviteLink.value = encodePublicKeyLink(myPublicKey.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => auth.primaryIdentity.value?.keyPair.publicKey, (pk) => {
|
||||||
|
if (pk) {
|
||||||
|
myPublicKey.value = pk
|
||||||
|
updateInviteLink()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function copyInviteLink() {
|
||||||
|
if (!inviteLink.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink.value)
|
||||||
|
copiedLink.value = true
|
||||||
|
setTimeout(() => { copiedLink.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = inviteLink.value
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
copiedLink.value = true
|
||||||
|
setTimeout(() => { copiedLink.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyMyPublicKey() {
|
||||||
|
if (!myPublicKey.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(myPublicKey.value)
|
||||||
|
copiedKey.value = true
|
||||||
|
setTimeout(() => { copiedKey.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = myPublicKey.value
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
copiedKey.value = true
|
||||||
|
setTimeout(() => { copiedKey.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddContact() {
|
||||||
|
addContactError.value = ''
|
||||||
|
const key = contactKey.value.trim()
|
||||||
|
if (!key) {
|
||||||
|
addContactError.value = 'Please enter a public key or invite link.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodePublicKeyLink(key)
|
||||||
|
const publicKey = decoded ?? key
|
||||||
|
|
||||||
|
if (publicKey.length < 32) {
|
||||||
|
addContactError.value = 'Invalid public key format.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = contactLabel.value.trim() || 'Contact'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.addContact(publicKey, label)
|
||||||
|
|
||||||
|
const primary = auth.primaryIdentity.value
|
||||||
|
if (primary?.keyPair) {
|
||||||
|
notesStore.api.sendInvitation(
|
||||||
|
primary.keyPair,
|
||||||
|
publicKey,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
label
|
||||||
|
).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
contactKey.value = ''
|
||||||
|
contactLabel.value = ''
|
||||||
|
emit('share-complete')
|
||||||
|
} catch {
|
||||||
|
addContactError.value = 'Failed to add contact. Check the public key.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQrScan(value: string) {
|
||||||
|
contactKey.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(pubKey: string, currentLabel: string) {
|
||||||
|
editingContact.value = pubKey
|
||||||
|
editLabel.value = currentLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishRename(pubKey: string) {
|
||||||
|
const label = editLabel.value.trim()
|
||||||
|
if (label) {
|
||||||
|
auth.renameContact(pubKey, label)
|
||||||
|
}
|
||||||
|
editingContact.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
editingContact.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
addContactError.value = ''
|
||||||
|
}
|
||||||
|
emit('update:open', open)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="open" @update:open="onOpenChange">
|
||||||
|
<DialogContent class="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share & Invite</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Share your public key to let others share notes with you, or add a contact.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Label class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your Invite</Label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="inviteLink"
|
||||||
|
readonly
|
||||||
|
class="font-mono text-xs flex-1 min-w-0"
|
||||||
|
placeholder="No key yet..."
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" @click="copyInviteLink" :disabled="!inviteLink">
|
||||||
|
<Check v-if="copiedLink" class="h-4 w-4 text-green-500" />
|
||||||
|
<Link v-else class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" class="flex-1" @click="copyMyPublicKey" :disabled="!myPublicKey">
|
||||||
|
<Copy class="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{{ copiedKey ? 'Copied!' : 'Copy Public Key' }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" class="flex-1" @click="qrDisplayOpen = true" :disabled="!myPublicKey">
|
||||||
|
<QrCode class="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Show QR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t pt-4 space-y-3">
|
||||||
|
<Label class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Add Contact</Label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="contact-label" class="text-xs">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="contact-label"
|
||||||
|
v-model="contactLabel"
|
||||||
|
placeholder="e.g. Alice"
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="contact-key" class="text-xs">Public Key or Invite Link</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
id="contact-key"
|
||||||
|
v-model="contactKey"
|
||||||
|
placeholder="Paste public key or syncpad:// link..."
|
||||||
|
class="font-mono text-xs min-h-[60px] flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" @click="qrScanOpen = true" class="shrink-0 self-start">
|
||||||
|
<QrCode class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="addContactError" class="text-xs text-destructive">{{ addContactError }}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
class="w-full"
|
||||||
|
@click="handleAddContact"
|
||||||
|
:disabled="!contactKey.trim()"
|
||||||
|
>
|
||||||
|
<UserPlus class="mr-2 h-4 w-4" />
|
||||||
|
Add Contact
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="auth.contacts.value.length > 0" class="border-t pt-3 space-y-2">
|
||||||
|
<Label class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Contacts ({{ auth.contacts.value.length }})
|
||||||
|
</Label>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="contact in auth.contacts.value"
|
||||||
|
:key="contact.publicKey"
|
||||||
|
class="flex items-center justify-between text-xs px-2 py-1.5 rounded-md bg-muted/50 group"
|
||||||
|
>
|
||||||
|
<template v-if="editingContact === contact.publicKey">
|
||||||
|
<div class="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
<Users class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
<Input
|
||||||
|
v-model="editLabel"
|
||||||
|
class="h-6 text-xs flex-1 min-w-0"
|
||||||
|
@keyup.enter="finishRename(contact.publicKey)"
|
||||||
|
@keyup.escape="cancelRename()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-0.5 shrink-0 ml-1">
|
||||||
|
<button @click="finishRename(contact.publicKey)" class="p-0.5 text-green-500 hover:bg-green-500/10 rounded">
|
||||||
|
<Check class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button @click="cancelRename()" class="p-0.5 text-muted-foreground hover:text-destructive rounded">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<Users class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
<span class="truncate">{{ contact.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-0.5 shrink-0 ml-1">
|
||||||
|
<button
|
||||||
|
@click="startRename(contact.publicKey, contact.label)"
|
||||||
|
class="p-0.5 text-muted-foreground hover:text-foreground rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Pencil class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="auth.removeContact(contact.publicKey)"
|
||||||
|
class="text-xs text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<QrCodeDisplay
|
||||||
|
v-model:open="qrDisplayOpen"
|
||||||
|
:value="inviteLink"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QrCodeScanner
|
||||||
|
v-model:open="qrScanOpen"
|
||||||
|
@scan-success="handleQrScan"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
27
www/src/components/ui/badge/Badge.vue
Normal file
27
www/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { BadgeVariants } from '.'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { badgeVariants } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
variant?: BadgeVariants['variant']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="badge"
|
||||||
|
:data-variant="variant"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
24
www/src/components/ui/badge/index.ts
Normal file
24
www/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Badge } from './Badge.vue'
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||||
|
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||||
|
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||||
|
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
31
www/src/components/ui/button/Button.vue
Normal file
31
www/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { ButtonVariants } from '.'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '.'
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
variant?: ButtonVariants['variant']
|
||||||
|
size?: ButtonVariants['size']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: 'button',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="button"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-size="size"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
35
www/src/components/ui/button/index.ts
Normal file
35
www/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Button } from './Button.vue'
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
|
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
|
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
|
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||||
|
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||||
|
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
'icon': 'size-8',
|
||||||
|
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
|
||||||
|
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||||
|
'icon-lg': 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
21
www/src/components/ui/card/Card.vue
Normal file
21
www/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
size?: 'default' | 'sm'
|
||||||
|
}>(), {
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
:data-size="size"
|
||||||
|
:class="cn('ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
www/src/components/ui/card/CardAction.vue
Normal file
17
www/src/components/ui/card/CardAction.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
www/src/components/ui/card/CardContent.vue
Normal file
17
www/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
:class="cn('px-4 group-data-[size=sm]/card:px-3', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
www/src/components/ui/card/CardDescription.vue
Normal file
17
www/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
www/src/components/ui/card/CardFooter.vue
Normal file
17
www/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
www/src/components/ui/card/CardHeader.vue
Normal file
17
www/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
:class="cn('gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
www/src/components/ui/card/CardTitle.vue
Normal file
17
www/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
7
www/src/components/ui/card/index.ts
Normal file
7
www/src/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { default as Card } from './Card.vue'
|
||||||
|
export { default as CardAction } from './CardAction.vue'
|
||||||
|
export { default as CardContent } from './CardContent.vue'
|
||||||
|
export { default as CardDescription } from './CardDescription.vue'
|
||||||
|
export { default as CardFooter } from './CardFooter.vue'
|
||||||
|
export { default as CardHeader } from './CardHeader.vue'
|
||||||
|
export { default as CardTitle } from './CardTitle.vue'
|
||||||
19
www/src/components/ui/dialog/Dialog.vue
Normal file
19
www/src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="dialog"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
15
www/src/components/ui/dialog/DialogClose.vue
Normal file
15
www/src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from 'reka-ui'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="dialog-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
53
www/src/components/ui/dialog/DialogContent.vue
Normal file
53
www/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { XIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import DialogOverlay from './DialogOverlay.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="dialog-content"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="dialog-close"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm">
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
23
www/src/components/ui/dialog/DialogDescription.vue
Normal file
23
www/src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
27
www/src/components/ui/dialog/DialogFooter.vue
Normal file
27
www/src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}>(), {
|
||||||
|
showCloseButton: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<DialogClose v-if="showCloseButton" as-child>
|
||||||
|
<Button variant="outline">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
www/src/components/ui/dialog/DialogHeader.vue
Normal file
17
www/src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
:class="cn('gap-2 flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
www/src/components/ui/dialog/DialogOverlay.vue
Normal file
21
www/src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogOverlay } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
60
www/src/components/ui/dialog/DialogScrollContent.vue
Normal file
60
www/src/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { XIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<XIcon class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
23
www/src/components/ui/dialog/DialogTitle.vue
Normal file
23
www/src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
15
www/src/components/ui/dialog/DialogTrigger.vue
Normal file
15
www/src/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from 'reka-ui'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
10
www/src/components/ui/dialog/index.ts
Normal file
10
www/src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { default as Dialog } from './Dialog.vue'
|
||||||
|
export { default as DialogClose } from './DialogClose.vue'
|
||||||
|
export { default as DialogContent } from './DialogContent.vue'
|
||||||
|
export { default as DialogDescription } from './DialogDescription.vue'
|
||||||
|
export { default as DialogFooter } from './DialogFooter.vue'
|
||||||
|
export { default as DialogHeader } from './DialogHeader.vue'
|
||||||
|
export { default as DialogOverlay } from './DialogOverlay.vue'
|
||||||
|
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||||
|
export { default as DialogTitle } from './DialogTitle.vue'
|
||||||
|
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||||
31
www/src/components/ui/input/Input.vue
Normal file
31
www/src/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn(
|
||||||
|
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
1
www/src/components/ui/input/index.ts
Normal file
1
www/src/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from './Input.vue'
|
||||||
26
www/src/components/ui/label/Label.vue
Normal file
26
www/src/components/ui/label/Label.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { LabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Label } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
1
www/src/components/ui/label/index.ts
Normal file
1
www/src/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from './Label.vue'
|
||||||
33
www/src/components/ui/scroll-area/ScrollArea.vue
Normal file
33
www/src/components/ui/scroll-area/ScrollArea.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ScrollAreaRootProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
ScrollAreaCorner,
|
||||||
|
ScrollAreaRoot,
|
||||||
|
ScrollAreaViewport,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import ScrollBar from './ScrollBar.vue'
|
||||||
|
|
||||||
|
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScrollAreaRoot
|
||||||
|
data-slot="scroll-area"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('relative', props.class)"
|
||||||
|
>
|
||||||
|
<ScrollAreaViewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
class="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ScrollAreaViewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaCorner />
|
||||||
|
</ScrollAreaRoot>
|
||||||
|
</template>
|
||||||
27
www/src/components/ui/scroll-area/ScrollBar.vue
Normal file
27
www/src/components/ui/scroll-area/ScrollBar.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ScrollAreaScrollbarProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
|
||||||
|
orientation: 'vertical',
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
:data-orientation="orientation"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none', props.class)"
|
||||||
|
>
|
||||||
|
<ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
class="rounded-full relative flex-1 bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaScrollbar>
|
||||||
|
</template>
|
||||||
2
www/src/components/ui/scroll-area/index.ts
Normal file
2
www/src/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ScrollArea } from './ScrollArea.vue'
|
||||||
|
export { default as ScrollBar } from './ScrollBar.vue'
|
||||||
29
www/src/components/ui/separator/Separator.vue
Normal file
29
www/src/components/ui/separator/Separator.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeparatorProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Separator } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<
|
||||||
|
SeparatorProps & { class?: HTMLAttributes['class'] }
|
||||||
|
>(), {
|
||||||
|
orientation: 'horizontal',
|
||||||
|
decorative: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
1
www/src/components/ui/separator/index.ts
Normal file
1
www/src/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Separator } from './Separator.vue'
|
||||||
19
www/src/components/ui/sheet/Sheet.vue
Normal file
19
www/src/components/ui/sheet/Sheet.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="sheet"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
15
www/src/components/ui/sheet/SheetClose.vue
Normal file
15
www/src/components/ui/sheet/SheetClose.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from 'reka-ui'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="sheet-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
61
www/src/components/ui/sheet/SheetContent.vue
Normal file
61
www/src/components/ui/sheet/SheetContent.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { XIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import SheetOverlay from './SheetOverlay.vue'
|
||||||
|
|
||||||
|
interface SheetContentProps extends DialogContentProps {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||||
|
side: 'right',
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'side', 'showCloseButton')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="sheet-content"
|
||||||
|
:data-side="side"
|
||||||
|
:class="cn('bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10', props.class)"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="sheet-close"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="absolute top-3 right-3" size="icon-sm">
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
21
www/src/components/ui/sheet/SheetDescription.vue
Normal file
21
www/src/components/ui/sheet/SheetDescription.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogDescription } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="sheet-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
15
www/src/components/ui/sheet/SheetFooter.vue
Normal file
15
www/src/components/ui/sheet/SheetFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
:class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
www/src/components/ui/sheet/SheetHeader.vue
Normal file
15
www/src/components/ui/sheet/SheetHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
:class="cn('gap-0.5 p-4 flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
www/src/components/ui/sheet/SheetOverlay.vue
Normal file
21
www/src/components/ui/sheet/SheetOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogOverlay } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
21
www/src/components/ui/sheet/SheetTitle.vue
Normal file
21
www/src/components/ui/sheet/SheetTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogTitle } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="sheet-title"
|
||||||
|
:class="cn('text-foreground text-base font-medium cn-font-heading', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
15
www/src/components/ui/sheet/SheetTrigger.vue
Normal file
15
www/src/components/ui/sheet/SheetTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from 'reka-ui'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="sheet-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
8
www/src/components/ui/sheet/index.ts
Normal file
8
www/src/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as Sheet } from './Sheet.vue'
|
||||||
|
export { default as SheetClose } from './SheetClose.vue'
|
||||||
|
export { default as SheetContent } from './SheetContent.vue'
|
||||||
|
export { default as SheetDescription } from './SheetDescription.vue'
|
||||||
|
export { default as SheetFooter } from './SheetFooter.vue'
|
||||||
|
export { default as SheetHeader } from './SheetHeader.vue'
|
||||||
|
export { default as SheetTitle } from './SheetTitle.vue'
|
||||||
|
export { default as SheetTrigger } from './SheetTrigger.vue'
|
||||||
25
www/src/components/ui/tabs/Tabs.vue
Normal file
25
www/src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<TabsRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<TabsRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="tabs"
|
||||||
|
:data-orientation="forwarded.orientation || 'horizontal'"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('gap-2 group/tabs flex data-horizontal:flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</TabsRoot>
|
||||||
|
</template>
|
||||||
21
www/src/components/ui/tabs/TabsContent.vue
Normal file
21
www/src/components/ui/tabs/TabsContent.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TabsContent } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsContent
|
||||||
|
data-slot="tabs-content"
|
||||||
|
:class="cn('text-sm flex-1 outline-none', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsContent>
|
||||||
|
</template>
|
||||||
29
www/src/components/ui/tabs/TabsList.vue
Normal file
29
www/src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsListProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { TabsListVariants } from '.'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TabsList } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { tabsListVariants } from '.'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TabsListProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
variant?: TabsListVariants['variant']
|
||||||
|
}>(), {
|
||||||
|
variant: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'variant')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsList
|
||||||
|
data-slot="tabs-list"
|
||||||
|
:data-variant="variant"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn(tabsListVariants({ variant }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsList>
|
||||||
|
</template>
|
||||||
29
www/src/components/ui/tabs/TabsTrigger.vue
Normal file
29
www/src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TabsTriggerProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsTrigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
:class="cn(
|
||||||
|
'gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*=size-])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
|
||||||
|
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
|
||||||
|
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsTrigger>
|
||||||
|
</template>
|
||||||
24
www/src/components/ui/tabs/index.ts
Normal file
24
www/src/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Tabs } from './Tabs.vue'
|
||||||
|
export { default as TabsContent } from './TabsContent.vue'
|
||||||
|
export { default as TabsList } from './TabsList.vue'
|
||||||
|
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||||
|
|
||||||
|
export const tabsListVariants = cva(
|
||||||
|
'rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-muted',
|
||||||
|
line: 'gap-1 bg-transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type TabsListVariants = VariantProps<typeof tabsListVariants>
|
||||||
28
www/src/components/ui/textarea/Textarea.vue
Normal file
28
www/src/components/ui/textarea/Textarea.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="textarea"
|
||||||
|
:class="cn('border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
1
www/src/components/ui/textarea/index.ts
Normal file
1
www/src/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Textarea } from './Textarea.vue'
|
||||||
19
www/src/components/ui/tooltip/Tooltip.vue
Normal file
19
www/src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
|
||||||
|
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<TooltipRootProps>()
|
||||||
|
const emits = defineEmits<TooltipRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="tooltip"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</TooltipRoot>
|
||||||
|
</template>
|
||||||
34
www/src/components/ui/tooltip/TooltipContent.vue
Normal file
34
www/src/components/ui/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
|
||||||
|
sideOffset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<TooltipContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
|
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</template>
|
||||||
14
www/src/components/ui/tooltip/TooltipProvider.vue
Normal file
14
www/src/components/ui/tooltip/TooltipProvider.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipProviderProps } from 'reka-ui'
|
||||||
|
import { TooltipProvider } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TooltipProviderProps>(), {
|
||||||
|
delayDuration: 0,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipProvider v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</TooltipProvider>
|
||||||
|
</template>
|
||||||
15
www/src/components/ui/tooltip/TooltipTrigger.vue
Normal file
15
www/src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipTriggerProps } from 'reka-ui'
|
||||||
|
import { TooltipTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<TooltipTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipTrigger
|
||||||
|
data-slot="tooltip-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</template>
|
||||||
4
www/src/components/ui/tooltip/index.ts
Normal file
4
www/src/components/ui/tooltip/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as Tooltip } from './Tooltip.vue'
|
||||||
|
export { default as TooltipContent } from './TooltipContent.vue'
|
||||||
|
export { default as TooltipProvider } from './TooltipProvider.vue'
|
||||||
|
export { default as TooltipTrigger } from './TooltipTrigger.vue'
|
||||||
179
www/src/lib/api.ts
Normal file
179
www/src/lib/api.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { signRequest } from './signature'
|
||||||
|
import type { Note, KeyPair, ShareInvitation } from '@/types'
|
||||||
|
|
||||||
|
interface CreatePayload {
|
||||||
|
id: string
|
||||||
|
encrypted_title: string
|
||||||
|
encrypted_content: string
|
||||||
|
iv: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePayload {
|
||||||
|
encrypted_title: string
|
||||||
|
encrypted_content: string
|
||||||
|
iv: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders(keyPair: KeyPair, timestamp: number, body: string) {
|
||||||
|
const nonce = crypto.randomUUID()
|
||||||
|
const signature = signRequest(keyPair.secretKey, timestamp, body)
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-publickey': keyPair.publicKey,
|
||||||
|
'x-signature': signature,
|
||||||
|
'x-nonce': nonce,
|
||||||
|
'x-timestamp': String(timestamp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiClient(baseUrl: string) {
|
||||||
|
return {
|
||||||
|
async listNotes(keyPair: KeyPair): Promise<Note[]> {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = ''
|
||||||
|
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||||
|
const res = await fetch(`${baseUrl}/api/notes`, { headers })
|
||||||
|
if (!res.ok) throw new Error(`Failed to list notes: ${res.statusText}`)
|
||||||
|
const data = await res.json()
|
||||||
|
return data.notes as Note[]
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNote(
|
||||||
|
keyPair: KeyPair,
|
||||||
|
id: string,
|
||||||
|
encryptedTitle: string,
|
||||||
|
encryptedContent: string,
|
||||||
|
iv: string
|
||||||
|
): Promise<Note> {
|
||||||
|
const payload: CreatePayload = {
|
||||||
|
id,
|
||||||
|
encrypted_title: encryptedTitle,
|
||||||
|
encrypted_content: encryptedContent,
|
||||||
|
iv,
|
||||||
|
}
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||||
|
const res = await fetch(`${baseUrl}/api/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Failed to create note: ${res.statusText}`)
|
||||||
|
const data = await res.json()
|
||||||
|
return data.note as Note
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateNote(
|
||||||
|
keyPair: KeyPair,
|
||||||
|
id: string,
|
||||||
|
encryptedTitle: string,
|
||||||
|
encryptedContent: string,
|
||||||
|
iv: string
|
||||||
|
): Promise<Note> {
|
||||||
|
const payload: UpdatePayload = {
|
||||||
|
encrypted_title: encryptedTitle,
|
||||||
|
encrypted_content: encryptedContent,
|
||||||
|
iv,
|
||||||
|
}
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||||
|
const res = await fetch(`${baseUrl}/api/notes/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Failed to update note: ${res.statusText}`)
|
||||||
|
const data = await res.json()
|
||||||
|
return data.note as Note
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteNote(keyPair: KeyPair, id: string): Promise<void> {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = ''
|
||||||
|
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||||
|
const res = await fetch(`${baseUrl}/api/notes/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Failed to delete note: ${res.statusText}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getSSEUrl(keyPair: KeyPair): string {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = ''
|
||||||
|
const signature = signRequest(keyPair.secretKey, timestamp, body)
|
||||||
|
const nonce = crypto.randomUUID()
|
||||||
|
return `${baseUrl}/api/notes/sync?publickey=${encodeURIComponent(keyPair.publicKey)}&signature=${encodeURIComponent(signature)}&nonce=${encodeURIComponent(nonce)}×tamp=${timestamp}`
|
||||||
|
},
|
||||||
|
|
||||||
|
async listInvitations(keyPair: KeyPair): Promise<ShareInvitation[]> {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = ''
|
||||||
|
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||||
|
const res = await fetch(`${baseUrl}/api/invitations`, { headers })
|
||||||
|
if (!res.ok) throw new Error(`Failed to list invitations: ${res.statusText}`)
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.invitations as any[]).map((raw: any) => ({
|
||||||
|
id: raw.id,
|
||||||
|
fromPublicKey: raw.from_public_key,
|
||||||
|
toPublicKey: raw.to_public_key,
|
||||||
|
encryptedGroupKey: raw.encrypted_group_key,
|
||||||
|
iv: raw.iv,
|
||||||
|
noteId: raw.note_id,
|
||||||
|
noteTitle: raw.note_title,
|
||||||
|
created_at: raw.created_at,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendInvitation(
|
||||||
|
keyPair: KeyPair,
|
||||||
|
toPublicKey: string,
|
||||||
|
encryptedGroupKey?: string,
|
||||||
|
iv?: string,
|
||||||
|
noteId?: string,
|
||||||
|
noteTitle?: string
|
||||||
|
): Promise<ShareInvitation> {
|
||||||
|
const payload: Record<string, string | undefined> = {
|
||||||
|
toPublicKey,
|
||||||
|
encryptedGroupKey: encryptedGroupKey ?? '',
|
||||||
|
iv: iv ?? '',
|
||||||
|
noteId,
|
||||||
|
noteTitle,
|
||||||
|
}
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||||
|
const res = await fetch(`${baseUrl}/api/invitations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Failed to send invitation: ${res.statusText}`)
|
||||||
|
const raw = await res.json()
|
||||||
|
const inv = raw.invitation
|
||||||
|
return {
|
||||||
|
id: inv.id,
|
||||||
|
fromPublicKey: inv.from_public_key,
|
||||||
|
toPublicKey: inv.to_public_key,
|
||||||
|
encryptedGroupKey: inv.encrypted_group_key,
|
||||||
|
iv: inv.iv,
|
||||||
|
noteId: inv.note_id,
|
||||||
|
noteTitle: inv.note_title,
|
||||||
|
created_at: inv.created_at,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async acceptInvitation(keyPair: KeyPair, invitationId: string): Promise<void> {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const body = ''
|
||||||
|
const headers = getAuthHeaders(keyPair, timestamp, body)
|
||||||
|
const res = await fetch(`${baseUrl}/api/invitations/${invitationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Failed to accept invitation: ${res.statusText}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
81
www/src/lib/crypto.ts
Normal file
81
www/src/lib/crypto.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
interface TweetNaclUtil {
|
||||||
|
decodeUTF8: (s: string) => Uint8Array
|
||||||
|
encodeUTF8: (arr: Uint8Array) => string
|
||||||
|
encodeBase64: (arr: Uint8Array) => string
|
||||||
|
decodeBase64: (s: string) => Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
import tweetnaclUtil from 'tweetnacl-util'
|
||||||
|
const util = tweetnaclUtil as unknown as {
|
||||||
|
decodeUTF8: (s: string) => Uint8Array
|
||||||
|
encodeUTF8: (arr: Uint8Array) => string
|
||||||
|
encodeBase64: (arr: Uint8Array) => string
|
||||||
|
decodeBase64: (s: string) => Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToArrayBuffer(base64: string): Uint8Array {
|
||||||
|
const binary = atob(base64)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptData(aesKey: CryptoKey, plaintext: string, iv: Uint8Array): Promise<string> {
|
||||||
|
const encoded = util.decodeUTF8(plaintext)
|
||||||
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||||
|
aesKey,
|
||||||
|
encoded as BufferSource
|
||||||
|
)
|
||||||
|
return arrayBufferToBase64(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptData(aesKey: CryptoKey, ciphertextBase64: string, iv: Uint8Array): Promise<string> {
|
||||||
|
const ciphertext = base64ToArrayBuffer(ciphertextBase64)
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||||
|
aesKey,
|
||||||
|
ciphertext as BufferSource
|
||||||
|
)
|
||||||
|
return util.encodeUTF8(new Uint8Array(decrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptNote(
|
||||||
|
aesKey: CryptoKey,
|
||||||
|
title: string,
|
||||||
|
content: string
|
||||||
|
): Promise<{ encryptedTitle: string; encryptedContent: string; iv: string }> {
|
||||||
|
const ivTitle = new Uint8Array(12)
|
||||||
|
const ivContent = new Uint8Array(12)
|
||||||
|
window.crypto.getRandomValues(ivTitle)
|
||||||
|
window.crypto.getRandomValues(ivContent)
|
||||||
|
const encryptedTitle = await encryptData(aesKey, title, ivTitle)
|
||||||
|
const encryptedContent = await encryptData(aesKey, content, ivContent)
|
||||||
|
const combinedIv = arrayBufferToBase64(ivTitle.buffer as ArrayBuffer) + '|' + arrayBufferToBase64(ivContent.buffer as ArrayBuffer)
|
||||||
|
return { encryptedTitle, encryptedContent, iv: combinedIv }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptNote(
|
||||||
|
aesKey: CryptoKey,
|
||||||
|
encryptedTitle: string,
|
||||||
|
encryptedContent: string,
|
||||||
|
ivBase64: string
|
||||||
|
): Promise<{ title: string; content: string }> {
|
||||||
|
const [ivTitleB64, ivContentB64] = ivBase64.split('|')
|
||||||
|
const ivTitle = base64ToArrayBuffer(ivTitleB64)
|
||||||
|
const ivContent = base64ToArrayBuffer(ivContentB64)
|
||||||
|
const title = await decryptData(aesKey, encryptedTitle, ivTitle)
|
||||||
|
const content = await decryptData(aesKey, encryptedContent, ivContent)
|
||||||
|
return { title, content }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user