Files
hyprcrd/public/index.php
T
2026-04-07 22:56:45 +02:00

853 lines
52 KiB
PHP

<?php
require_once __DIR__ . '/../src/bootstrap.php';
$db = Database::connect();
// ── Security headers ──
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
// ── Session ──
session_set_cookie_params([
'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Strict',
]);
session_start();
// CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// ── Auth ──
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$basePath = dirname($_SERVER['SCRIPT_NAME']);
$route = ($basePath === '/' || $basePath === '\\') ? $path : str_replace($basePath, '', $path);
$pwHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
$isSetup = !$pwHash;
$isLoggedIn = !empty($_SESSION['hyprcrd_auth']);
// Auth API endpoints (always accessible)
if (str_starts_with($route, '/api/auth')) {
error_reporting(0);
header('Content-Type: application/json');
// GET /api/auth/status
if ($route === '/api/auth/status' && $method === 'GET') {
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token']]);
exit;
}
// POST /api/auth/setup — set initial password
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
$input = json_decode(file_get_contents('php://input'), true);
$pw = $input['password'] ?? '';
if (strlen($pw) < 4) {
http_response_code(400);
echo json_encode(['error' => 'password too short (min 4)']);
exit;
}
$hash = password_hash($pw, PASSWORD_BCRYPT);
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_hash', :hash)")->execute(['hash' => $hash]);
session_regenerate_id(true);
$_SESSION['hyprcrd_auth'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
exit;
}
// POST /api/auth/login (rate limited)
if ($route === '/api/auth/login' && $method === 'POST') {
$attempts = $_SESSION['login_attempts'] ?? 0;
$lastAttempt = $_SESSION['login_last'] ?? 0;
if ($attempts >= 5 && time() - $lastAttempt < 60) {
http_response_code(429);
echo json_encode(['error' => 'too many attempts, wait 60s']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$pw = $input['password'] ?? '';
if ($pwHash && password_verify($pw, $pwHash)) {
session_regenerate_id(true);
$_SESSION['hyprcrd_auth'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$_SESSION['login_attempts'] = 0;
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
} else {
$_SESSION['login_attempts'] = $attempts + 1;
$_SESSION['login_last'] = time();
usleep(300000); // 300ms delay against timing attacks
http_response_code(401);
echo json_encode(['error' => 'wrong password']);
}
exit;
}
// POST /api/auth/logout
if ($route === '/api/auth/logout' && $method === 'POST') {
$_SESSION = [];
session_destroy();
echo json_encode(['ok' => true]);
exit;
}
http_response_code(404);
echo json_encode(['error' => 'not found']);
exit;
}
// All other API/page requests require auth (unless setup mode)
if (!$isSetup && !$isLoggedIn) {
if (str_starts_with($route, '/api/')) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'unauthorized']);
exit;
}
// For page requests, still serve the HTML shell — JS will show login dialog
}
if (str_starts_with($route, '/api/')) {
error_reporting(0);
header('Content-Type: application/json');
// CSRF check on all state-changing requests
if (in_array($method, ['POST', 'PATCH', 'DELETE'])) {
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) {
http_response_code(403);
echo json_encode(['error' => 'invalid csrf token']);
exit;
}
}
// Input sanitization
function validColor($c) { return preg_match('/^#[0-9a-fA-F]{6}$/', $c ?? '') ? $c : '#ffffff'; }
function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; }
function extractTags($body) {
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches);
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
}
// Cards
if ($route === '/api/cards' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$title = trim($input['title'] ?? '');
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
$pos = $input['position'] ?? 0;
$body = $input['body'] ?? '';
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->execute([
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
'parent_id' => validInt($input['parent_id'] ?? null, null),
'position' => validInt($pos),
'tags' => extractTags($body),
]);
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
exit;
}
if ($route === '/api/cards' && $method === 'GET') {
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
$fields = []; $params = ['id' => $m[1]];
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id'] as $f) {
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
}
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
$card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit;
}
// Buckets
if ($route === '/api/buckets' && $method === 'GET') {
echo json_encode($db->query('SELECT * FROM buckets ORDER BY position ASC, created_at ASC')->fetchAll()); exit;
}
if ($route === '/api/buckets' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$name = trim($input['name'] ?? '');
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; }
$db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]);
echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
$fields = []; $params = ['id' => $m[1]];
foreach (['name', 'position', 'color'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
$b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') {
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]);
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
}
// List all upload files
if ($route === '/api/uploads' && $method === 'GET') {
$dir = __DIR__ . '/uploads';
$files = [];
if (is_dir($dir)) {
foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) {
$name = basename($f);
$files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)];
}
}
usort($files, fn($a, $b) => $b['modified'] - $a['modified']);
echo json_encode($files);
exit;
}
// Delete an upload file
if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') {
$file = __DIR__ . '/uploads/' . basename($m[1]);
if (is_file($file)) {
unlink($file);
echo json_encode(['ok' => true]);
} else {
http_response_code(404);
echo json_encode(['error' => 'file not found']);
}
exit;
}
// Upload (max 10MB, real MIME check)
if ($route === '/api/upload' && $method === 'POST') {
$uploadDir = __DIR__ . '/uploads';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'no file', 'debug_files' => array_keys($_FILES), 'debug_method' => $method, 'debug_ct' => $_SERVER['CONTENT_TYPE'] ?? 'none']);
exit;
}
$file = $_FILES['file'];
if ($file['size'] > 10 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['error' => 'file too large (max 10MB)']);
exit;
}
// Check MIME — finfo first, fallback to extension for audio
$mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg'];
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file['tmp_name']);
$ext = $mimeMap[$realMime] ?? null;
// Fallback: trust extension for audio when finfo returns octet-stream
if (!$ext && ($realMime === 'application/octet-stream' || str_starts_with($realMime, 'audio/'))) {
$origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$ext = $extMap[$origExt] ?? null;
}
if (!$ext) {
http_response_code(400);
echo json_encode(['error' => 'invalid file type', 'detected' => $realMime]);
exit;
}
$name = bin2hex(random_bytes(16)) . '.' . $ext;
$dest = $uploadDir . '/' . $name;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['error' => 'upload failed']);
exit;
}
http_response_code(201);
echo json_encode(['url' => 'uploads/' . $name, 'name' => $name]);
exit;
}
// Export — Markdown vault (Obsidian / Logseq compatible)
if ($route === '/api/export-md' && $method === 'GET') {
$ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null;
if ($ids) {
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id");
$stmt->execute($ids);
$cards = $stmt->fetchAll();
} else {
$cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
}
$allCards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
$buckets = $db->query('SELECT * FROM buckets ORDER BY position, id')->fetchAll();
$bucketMap = [];
foreach ($buckets as $b) $bucketMap[$b['id']] = $b['name'];
$cardMap = [];
foreach ($allCards as $c) $cardMap[$c['id']] = $c;
// Sanitise filename
$safe = function ($name) {
$name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $name);
return trim($name) ?: 'untitled';
};
$tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_md_');
$zip = new ZipArchive();
$zip->open($tmp, ZipArchive::OVERWRITE);
$usedNames = [];
$allBodies = '';
foreach ($cards as $c) {
// Build folder path: bucket/group/ (skip folders for single-card export)
$folder = '';
if (!$ids || count($ids) > 1) {
if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) {
$folder = $safe($bucketMap[$c['bucket_id']]) . '/';
}
if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) {
$folder .= $safe($cardMap[$c['parent_id']]['title']) . '/';
}
}
$baseName = $safe($c['title']);
$fileName = $folder . $baseName . '.md';
// Deduplicate
$n = 1;
while (isset($usedNames[$fileName])) {
$fileName = $folder . $baseName . ' ' . (++$n) . '.md';
}
$usedNames[$fileName] = true;
// YAML frontmatter
$fm = "---\n";
$fm .= 'id: ' . $c['id'] . "\n";
$fm .= 'color: "' . $c['color'] . "\"\n";
if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) {
$fm .= 'bucket: "' . addcslashes($bucketMap[$c['bucket_id']], '"') . "\"\n";
}
if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) {
$fm .= 'parent: "' . addcslashes($cardMap[$c['parent_id']]['title'], '"') . "\"\n";
}
$fm .= 'created: ' . $c['created_at'] . "\n";
$fm .= 'updated: ' . $c['updated_at'] . "\n";
if (!empty($c['tags'])) {
$fm .= 'tags: [' . implode(', ', array_map(fn($t) => '"' . trim($t) . '"', explode(',', $c['tags']))) . "]\n";
}
$fm .= "---\n\n";
// Body: rewrite upload paths to attachments/
$body = $c['body'] ?? '';
$allBodies .= "\n" . $body;
$body = preg_replace('#uploads/#', 'attachments/', $body);
$zip->addFromString($fileName, $fm . '# ' . $c['title'] . "\n\n" . $body);
}
// Copy referenced uploads → attachments/
$uploadDir = __DIR__ . '/uploads';
if (is_dir($uploadDir)) {
foreach (glob($uploadDir . '/*') as $f) {
$name = basename($f);
if (is_file($f) && (!$ids || str_contains($allBodies, $name))) {
$zip->addFile($f, 'attachments/' . $name);
}
}
}
$zip->close();
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="hyprcrd-vault-' . date('Y-m-d') . '.zip"');
header('Content-Length: ' . filesize($tmp));
readfile($tmp);
unlink($tmp);
exit;
}
// Export — full or selective
if ($route === '/api/export' && $method === 'GET') {
$ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null;
if ($ids) {
$ph = implode(',', array_fill(0, count($ids), '?'));
$cards = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id")->execute($ids) ? $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id") : null;
// Re-query properly
$stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id");
$stmt->execute($ids);
$cards = $stmt->fetchAll();
$bucketIds = array_unique(array_filter(array_column($cards, 'bucket_id')));
$buckets = [];
if ($bucketIds) {
$bph = implode(',', array_fill(0, count($bucketIds), '?'));
$bstmt = $db->prepare("SELECT * FROM buckets WHERE id IN ($bph)");
$bstmt->execute(array_values($bucketIds));
$buckets = $bstmt->fetchAll();
}
} else {
$cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll();
$buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll();
}
$tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_');
$zip = new ZipArchive();
$zip->open($tmp, ZipArchive::OVERWRITE);
$zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Only include referenced uploads
$uploadDir = __DIR__ . '/uploads';
$allBodies = implode("\n", array_column($cards, 'body'));
if (is_dir($uploadDir)) {
foreach (glob($uploadDir . '/*') as $f) {
$name = basename($f);
if (is_file($f) && (!$ids || str_contains($allBodies, $name))) {
$zip->addFile($f, 'uploads/' . $name);
}
}
}
$zip->close();
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="hyprcrd-export-' . date('Y-m-d') . '.zip"');
header('Content-Length: ' . filesize($tmp));
readfile($tmp);
unlink($tmp);
exit;
}
// Import — ZIP with JSON dump + uploads
if ($route === '/api/import' && $method === 'POST') {
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'no file']); exit;
}
$zip = new ZipArchive();
if ($zip->open($_FILES['file']['tmp_name']) !== true) {
http_response_code(400);
echo json_encode(['error' => 'invalid zip']); exit;
}
$json = $zip->getFromName('data.json');
if (!$json) {
$zip->close();
http_response_code(400);
echo json_encode(['error' => 'no data.json in archive']); exit;
}
$data = json_decode($json, true);
if (!$data || !isset($data['buckets']) || !isset($data['cards'])) {
$zip->close();
http_response_code(400);
echo json_encode(['error' => 'invalid data.json']); exit;
}
$merge = !empty($data['selective']) || !empty($_GET['merge']);
if (!$merge) {
$db->exec('DELETE FROM cards');
$db->exec('DELETE FROM buckets');
}
// Import buckets (merge: match by name, create if missing)
$bucketMap = [];
foreach ($data['buckets'] as $b) {
$oldId = $b['id'];
if ($merge) {
$existing = $db->prepare('SELECT id FROM buckets WHERE name = :name');
$existing->execute(['name' => $b['name']]);
$found = $existing->fetchColumn();
if ($found) { $bucketMap[$oldId] = $found; continue; }
}
$stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)');
$stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]);
$bucketMap[$oldId] = $db->lastInsertId();
}
// Import cards
foreach ($data['cards'] as $c) {
$bucketId = ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) ? $bucketMap[$c['bucket_id']] : null;
$stmt = $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :created_at, :updated_at)');
$stmt->execute([
'title' => $c['title'], 'body' => $c['body'], 'color' => $c['color'],
'bucket_id' => $bucketId, 'position' => $c['position'] ?? 0,
'created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'),
'updated_at' => $c['updated_at'] ?? date('Y-m-d H:i:s'),
]);
}
// Extract uploads
$uploadDir = __DIR__ . '/uploads';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') {
$content = $zip->getFromIndex($i);
if ($content !== false) {
file_put_contents($uploadDir . '/' . basename($name), $content);
}
}
}
$zip->close();
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]);
exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
}
// ── HTML Shell ──
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hyprcrd</title>
<link rel="stylesheet" href="css/fonts.css">
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
<link rel="stylesheet" href="css/tailwind.out.css">
<script src="js/vendor/marked.min.js"></script>
<script src="js/vendor/purify.min.js"></script>
<link rel="stylesheet" href="css/style.css?v=<?= time() ?>">
<script>
window.themes = {
slate: { label:'Slate', vars:{ '--t-bg':'#1e293b','--t-surface':'#273349','--t-raised':'#2e3d56','--t-border':'#384a64','--t-dim':'#64748b','--t-muted':'#94a3b8','--t-text':'#cbd5e1','--t-bright':'#f1f5f9','--t-accent':'#38bdf8','--t-accent2':'#818cf8','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.04)','--t-overlay':'rgba(15,23,42,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(15,23,42,0.22) 0%, rgba(15,23,42,0.7) 62%, rgba(15,23,42,0.86) 100%)' }},
phosphor: { label:'Phosphor', vars:{ '--t-bg':'#0a0f0a','--t-surface':'#0f1a0f','--t-raised':'#142214','--t-border':'#1a3a1a','--t-dim':'#2d5a2d','--t-muted':'#4a8a4a','--t-text':'#7bc67b','--t-bright':'#b8f0b8','--t-accent':'#4ade80','--t-accent2':'#22d3ee','--t-danger':'#f87171','--t-scanline':'rgba(0,0,0,0.1)','--t-overlay':'rgba(5,10,5,0.85)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(10,15,10,0.22) 0%, rgba(10,15,10,0.7) 62%, rgba(10,15,10,0.86) 100%)' }},
midnight: { label:'Midnight', vars:{ '--t-bg':'#0f172a','--t-surface':'#162033','--t-raised':'#1c2940','--t-border':'#263650','--t-dim':'#475882','--t-muted':'#7088b0','--t-text':'#a8bdd8','--t-bright':'#e2e8f0','--t-accent':'#a78bfa','--t-accent2':'#f472b6','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.06)','--t-overlay':'rgba(10,15,30,0.8)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(15,23,42,0.22) 0%, rgba(15,23,42,0.7) 62%, rgba(15,23,42,0.86) 100%)' }},
amber: { label:'Amber', vars:{ '--t-bg':'#1a1408','--t-surface':'#231c0e','--t-raised':'#2c2414','--t-border':'#3d3520','--t-dim':'#6b5c38','--t-muted':'#a08c5a','--t-text':'#d4c088','--t-bright':'#fbe8b0','--t-accent':'#fbbf24','--t-accent2':'#fb923c','--t-danger':'#ef4444','--t-scanline':'rgba(0,0,0,0.08)','--t-overlay':'rgba(20,16,5,0.8)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(26,20,8,0.22) 0%, rgba(26,20,8,0.7) 62%, rgba(26,20,8,0.86) 100%)' }},
void: { label:'Void', vars:{ '--t-bg':'#000000','--t-surface':'#0a0a0a','--t-raised':'#141414','--t-border':'#222222','--t-dim':'#444444','--t-muted':'#666666','--t-text':'#a0a0a0','--t-bright':'#e0e0e0','--t-accent':'#ffffff','--t-accent2':'#888888','--t-danger':'#ff4444','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(0,0,0,0.85)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.75) 62%, rgba(0,0,0,0.9) 100%)' }},
paper: { label:'Paper', vars:{ '--t-bg':'#f4f4f5','--t-surface':'#ffffff','--t-raised':'#e8e8eb','--t-border':'#d4d4d8','--t-dim':'#a1a1aa','--t-muted':'#71717a','--t-text':'#3f3f46','--t-bright':'#18181b','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(244,244,245,0.8)','--t-font-pixel':'"JetBrains Mono", monospace','--t-cover-gradient':'linear-gradient(180deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.75) 40%, rgba(255,255,255,0.3) 100%)' }},
};
window.applyTheme = function(name) {
const t = themes[name] || themes.slate;
for (const [k,v] of Object.entries(t.vars)) document.documentElement.style.setProperty(k,v);
localStorage.setItem('hyprcrd-theme', name);
document.querySelectorAll('[data-theme-dot]').forEach(el => {
const a = el.dataset.themeDot === name;
el.classList.toggle('ring-2',a); el.classList.toggle('ring-t-accent',a); el.classList.toggle('ring-offset-1',a); el.classList.toggle('ring-offset-t-bg',a);
});
}
applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
</script>
</head>
<!-- tailwind safelist: border-t-bright border-transparent scale-125 text-t-accent line-clamp-2 line-clamp-5 border-t-danger text-t-danger pr-16 text-amber-400 -->
<body class="bg-t-bg text-t-text min-h-screen transition-colors duration-200 flex flex-col">
<header id="main-header" class="sticky top-0 z-40 bg-t-bg border-b border-t-border shadow-[0_4px_20px_-4px_rgba(0,0,0,0.3)]" style="overflow:hidden;">
<div class="flex items-center justify-between px-6 py-3 relative z-[1]">
<div class="flex items-center gap-3">
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
<h1 class="font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
<span class="text-[10px] text-t-dim font-pixel ml-1">v0.1</span>
</div>
<div class="flex items-center gap-3">
<button id="gallery-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">images</span>
</button>
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</button>
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">tasks</span>
<span id="tasks-open-count" class="font-pixel text-[9px] px-1.5 py-0.5 border border-t-border rounded text-t-dim">0</span>
</button>
<button id="add-btn" class="flex items-center gap-2 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
<i class="ph ph-plus-square text-sm"></i><span class="font-pixel text-[11px]">new_card</span>
</button>
</div>
</div>
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
<button id="sort-toggle" class="flex items-center gap-1.5 px-2 py-1 text-[10px] font-pixel text-t-dim hover:text-t-accent transition-colors shrink-0 ml-2">
<i class="ph ph-sort-ascending text-xs"></i>
<span id="sort-label">manual</span>
</button>
</div>
<div id="group-bar" class="hidden flex items-center gap-2 px-6 pb-2 relative z-[1]"></div>
</header>
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-24"></div>
<footer class="fixed inset-x-0 bottom-0 z-40 border-t border-t-border bg-t-bg shadow-[0_-4px_20px_-4px_rgba(0,0,0,0.3)] px-6 py-2 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-wrap">
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
<button id="import-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">import</span>
</button>
<button id="export-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
</button>
<button id="export-md-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-vault text-sm"></i><span class="font-pixel text-[10px]">vault</span>
</button>
<button id="logout-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
</button>
<div id="theme-picker" class="theme-picker">
<button id="theme-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-sliders-horizontal text-sm"></i><span class="font-pixel text-[10px]">settings</span><i class="ph ph-caret-down text-[10px]"></i>
</button>
<div class="theme-menu bg-t-surface border border-t-border shadow-2xl p-2 min-w-[170px]">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim px-2 pb-1">theme</div>
<div id="theme-list" class="space-y-1"></div>
</div>
</div>
</div>
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap absolute left-1/2 -translate-x-1/2"></div>
<div class="font-pixel text-[10px] text-t-dim whitespace-nowrap">© <?= date('Y') ?> hyprcrd</div>
</footer>
<!-- Dialog -->
<div class="overlay fixed inset-0 z-[90] flex justify-center items-center px-4" style="background:var(--t-overlay)" id="dialog">
<div class="bg-t-surface border border-t-border w-full max-w-sm">
<div class="flex items-center gap-2 px-4 py-3 border-b border-t-border">
<i class="ph ph-warning text-t-accent text-sm"></i>
<h2 id="dialog-title" class="font-pixel text-xs text-t-accent"></h2>
<div class="flex-1"></div>
<button id="dialog-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div class="p-4 space-y-3">
<p id="dialog-msg" class="text-xs text-t-text"></p>
<input type="text" id="dialog-input" class="hidden w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-sm focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono">
</div>
<div class="flex justify-end gap-2 px-4 py-3 border-t border-t-border">
<button id="dialog-cancel" class="font-pixel text-[10px] px-3 py-1.5 text-xs border border-t-border text-t-muted hover:text-t-bright transition-colors">cancel</button>
<button id="dialog-ok" class="font-pixel text-[10px] px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">ok</button>
</div>
</div>
</div>
<!-- Search -->
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1.5 py-0.5 font-pixel">esc</kbd>
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div class="flex flex-1 min-h-0">
<div id="search-results" class="overflow-y-auto w-2/5 min-w-[220px] border-r border-t-border"></div>
<div class="flex-1 overflow-y-auto p-5">
<div id="search-preview-empty" class="flex flex-col items-center justify-center h-full text-t-dim"><i class="ph ph-article text-3xl mb-2"></i><p class="font-pixel text-[10px]">select a card</p></div>
<div id="search-preview-content" class="hidden">
<div class="card-bar w-16 mb-4 rounded-sm" id="search-preview-color"></div>
<h2 id="search-preview-title" class="font-pixel text-t-bright text-sm mb-4 leading-relaxed"></h2>
<div id="search-preview-body" class="prose text-xs text-t-text"></div>
<div id="search-preview-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
<div id="search-preview-meta" class="mt-6 pt-3 border-t border-t-border text-[10px] text-t-dim font-pixel"></div>
</div>
</div>
</div>
<div id="search-footer" class="hidden px-4 py-2 border-t border-t-border flex items-center gap-4 text-[10px] text-t-dim font-pixel">
<span><i class="ph ph-arrow-up text-xs"></i><i class="ph ph-arrow-down text-xs"></i> navigate</span>
<span><i class="ph ph-arrow-elbow-down-left text-xs"></i> open</span><span>esc close</span>
</div>
</div>
</div>
<!-- Tasks -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="tasks">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:54px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
<i class="ph ph-check-square-offset text-t-accent2 text-base"></i>
<h2 class="font-pixel text-xs text-t-accent2">global_tasks</h2>
<div class="flex-1"></div>
<select id="tasks-status-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent2 focus:outline-none cursor-pointer">
<option value="open">open</option>
<option value="done">done</option>
<option value="all">all</option>
</select>
<select id="tasks-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent2 focus:outline-none cursor-pointer">
<option value="">all buckets</option>
<option value="none">unsorted</option>
</select>
<button id="tasks-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div id="tasks-summary" class="px-4 py-2 border-b border-t-border text-[10px] text-t-dim font-pixel"></div>
<div id="tasks-list" class="flex-1 overflow-y-auto"></div>
</div>
</div>
<!-- Detail -->
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="detail">
<div class="bg-t-surface border border-t-border flex absolute" style="inset:40px;">
<!-- Group sidebar (hidden for single cards) -->
<div id="detail-sidebar" class="hidden w-56 shrink-0 border-r border-t-border flex flex-col">
<div class="px-3 py-3 border-b border-t-border flex items-center gap-2">
<i class="ph ph-stack text-t-accent text-sm"></i>
<span class="font-pixel text-[10px] text-t-accent">group</span>
<span id="detail-sidebar-count" class="font-pixel text-[9px] text-t-dim ml-auto"></span>
</div>
<div id="detail-sidebar-list" class="flex-1 overflow-y-auto"></div>
</div>
<!-- Main content -->
<div class="flex-1 flex flex-col min-w-0">
<div class="card-bar w-full shrink-0" id="detail-color"></div>
<div id="detail-controls" class="flex items-center gap-2 px-4 py-2 border-b border-t-border shrink-0">
<button id="detail-edit" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span>
</button>
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">del</span>
</button>
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="zen mode">
<i class="ph ph-corners-out text-sm"></i>
</button>
<div class="flex-1"></div>
<button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="split view">
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">split</span>
</button>
<button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div id="detail-scroll" class="p-6 pt-5 flex-1 overflow-y-auto">
<h2 id="detail-title" class="font-pixel text-t-bright text-lg mb-4 leading-relaxed"></h2>
<div id="detail-body" class="prose text-sm text-t-text"></div>
<div id="detail-tags" class="hidden flex flex-wrap gap-1.5 mt-4"></div>
<div id="detail-backlinks" class="hidden mt-6 pt-4 border-t border-t-border">
<div class="font-pixel text-[10px] text-t-dim mb-2 flex items-center gap-1.5"><i class="ph ph-arrow-bend-left-up text-xs"></i> referenced_by</div>
<div id="detail-backlinks-list" class="flex flex-wrap gap-2"></div>
</div>
<div id="detail-meta" class="mt-8 pt-4 border-t border-t-border text-[11px] text-t-dim font-pixel"></div>
</div>
</div>
<!-- Split right pane -->
<div id="split-right" class="hidden w-2/5 shrink-0 border-l border-t-border flex flex-col min-w-0">
<div class="flex items-center gap-2 px-3 py-2 border-b border-t-border shrink-0">
<i class="ph ph-magnifying-glass text-t-dim text-xs"></i>
<input type="text" id="split-search" class="flex-1 bg-transparent text-xs text-t-bright placeholder:text-t-dim font-mono focus:outline-none" placeholder="search all cards..." autocomplete="off">
<button id="split-close" class="text-t-dim hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
</div>
<div id="split-picks" class="overflow-y-auto border-b border-t-border"></div>
<div id="split-content" class="flex-1 overflow-y-auto p-5"></div>
</div>
</div>
</div>
<div id="link-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:220px;max-width:360px;"></div>
<div id="tag-ac" class="fixed z-[80] bg-t-surface border border-t-border shadow-xl max-h-48 overflow-y-auto hidden" style="min-width:180px;max-width:300px;"></div>
<!-- Gallery -->
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
<i class="ph ph-images text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">images</h2>
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
<div class="flex-1"></div>
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div id="gallery-grid" class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 content-start"></div>
</div>
</div>
<!-- Selection action bar -->
<div id="select-bar" class="fixed bottom-16 left-1/2 -translate-x-1/2 z-[45] bg-t-surface border border-t-border shadow-2xl px-4 py-2 flex items-center gap-3 hidden" style="border-radius:6px;">
<span id="select-count" class="font-pixel text-[10px] text-t-accent"></span>
<button id="select-export" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
</button>
<button id="select-clear" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">clear</span>
</button>
</div>
<!-- Context menu -->
<div id="ctx-menu" class="fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden" style="min-width:180px;">
<div id="ctx-items" class="py-1"></div>
<div id="ctx-colors" class="hidden px-3 py-2 border-t border-t-border">
<div class="flex flex-wrap gap-1.5" id="ctx-color-grid"></div>
</div>
<div id="ctx-buckets" class="hidden border-t border-t-border py-1"></div>
<div id="ctx-members" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
<div id="ctx-tags" class="hidden border-t border-t-border py-1">
<div class="px-3 py-1.5"><input id="ctx-tag-input" type="text" placeholder="#tag..." class="w-full bg-t-bg border border-t-border text-t-text text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none"></div>
<div id="ctx-tag-list" class="max-h-36 overflow-y-auto"></div>
</div>
</div>
<!-- Editor -->
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
<div class="card-bar w-full shrink-0" id="editor-color"></div>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
<div class="flex items-center gap-2">
<button id="modal-cancel" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
<button id="modal-save" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="modal-save-view" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">save+view</span></button>
<button id="modal-save-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save+close</span></button>
</div>
</div>
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
<input type="text" id="inp-title" placeholder="> title..." class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
<select id="inp-bucket" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">no bucket</option></select>
<div id="color-presets" class="flex gap-1.5 shrink-0"></div>
<input type="hidden" id="inp-color" value="#38bdf8">
</div>
<div class="flex items-center gap-1 px-4 py-1.5 border-b border-t-border shrink-0" id="md-toolbar">
<button data-md="heading" title="Heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button>
<button data-md="bold" title="Bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button>
<button data-md="italic" title="Italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button>
<button data-md="strikethrough" title="Strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="code" title="Code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button>
<button data-md="codeblock" title="Code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="ul" title="List" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
<button data-md="tree" title="Tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
<button data-md="ol" title="Numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
<button data-md="quote" title="Quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
<button data-md="todo" title="Todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="link" title="Link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
<button data-md="cardlink" title="Card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
<div class="flex-1"></div>
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">preview</span></button>
</div>
<div class="flex flex-1 min-h-0">
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none overflow-hidden shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; line-height:1.625;"></div>
<div class="flex-1 relative min-h-full bg-t-bg">
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
</div>
<div id="editor-preview" class="flex-1 border-l border-t-border bg-t-bg p-4 overflow-y-auto hidden"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
</div>
</div>
</div>
<script type="module" src="js/app.js?v=<?= time() ?>"></script>
</body>
</html>