bak
This commit is contained in:
commit
e85b5203cd
15 files changed
+2779
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
data/*.db
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/data/*.db
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitkeep
|
||||||
|
/vendor/
|
||||||
|
.env
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM php:8.3-apache
|
||||||
|
|
||||||
|
# Enable mod_rewrite
|
||||||
|
RUN a2enmod rewrite
|
||||||
|
|
||||||
|
# Install SQLite dev libs + PDO extension
|
||||||
|
RUN apt-get update && apt-get install -y libsqlite3-dev libzip-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN docker-php-ext-install pdo_sqlite zip
|
||||||
|
|
||||||
|
# Set document root to public/
|
||||||
|
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
|
||||||
|
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \
|
||||||
|
/etc/apache2/sites-available/*.conf \
|
||||||
|
/etc/apache2/apache2.conf
|
||||||
|
|
||||||
|
# Allow .htaccess overrides
|
||||||
|
RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \
|
||||||
|
/etc/apache2/apache2.conf
|
||||||
|
|
||||||
|
COPY . /var/www/html
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["docker-entrypoint.sh"]
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
define('APP_NAME', 'hyprcrd');
|
||||||
|
define('APP_VERSION', '0.1.0');
|
||||||
|
|
||||||
|
// Database path — writable directory, outside webroot
|
||||||
|
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||||
|
|
||||||
|
// Base URL auto-detection (works in Docker and hosted environments)
|
||||||
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
||||||
|
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
||||||
Whitespace-only changes.
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8088:80"
|
||||||
|
volumes:
|
||||||
|
- ./data:/var/www/html/data
|
||||||
|
- ./src:/var/www/html/src
|
||||||
|
- ./public:/var/www/html/public
|
||||||
|
- ./config.php:/var/www/html/config.php
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
chown www-data:www-data /var/www/html/data
|
||||||
|
chmod 777 /var/www/html/data
|
||||||
|
mkdir -p /var/www/html/public/uploads
|
||||||
|
chmod 777 /var/www/html/public/uploads
|
||||||
|
exec apache2-foreground
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
body { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.font-pixel { font-family: var(--t-font-pixel, 'Silkscreen', cursive) !important; }
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--t-border) transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--t-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
|
||||||
|
*::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|
||||||
|
/* Scanlines */
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blinking cursor */
|
||||||
|
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* Line clamp */
|
||||||
|
.line-clamp-2, .line-clamp-3 { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.line-clamp-2 { -webkit-line-clamp: 2; }
|
||||||
|
.line-clamp-3 { -webkit-line-clamp: 3; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card-bar { image-rendering: pixelated; height: 3px; }
|
||||||
|
.card-layer { position: relative; z-index: 1; }
|
||||||
|
.card-has-cover {
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-cover-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.22) 0%, rgba(15, 23, 42, 0.7) 62%, rgba(15, 23, 42, 0.86) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group stacking effect */
|
||||||
|
.card-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.card-group::before,
|
||||||
|
.card-group::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px; right: 4px;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.card-group::before { top: 4px; opacity: 0.6; }
|
||||||
|
.card-group::after { top: 8px; opacity: 0.3; left: 8px; right: 8px; }
|
||||||
|
.card-group-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group sidebar */
|
||||||
|
.sidebar-card { border-left-width: 2px; }
|
||||||
|
|
||||||
|
/* Drop-on-card group indicator */
|
||||||
|
.card-drop-group {
|
||||||
|
outline: 2px solid var(--t-accent2) !important;
|
||||||
|
outline-offset: -2px;
|
||||||
|
box-shadow: 0 0 20px -4px var(--t-accent2) !important;
|
||||||
|
}
|
||||||
|
.card-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
border: 2px solid var(--t-border);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.group:hover .card-thumb { border-color: var(--t-muted); }
|
||||||
|
.card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
||||||
|
.card-dragging { opacity: 0.3; transform: scale(0.95); }
|
||||||
|
|
||||||
|
/* Drop indicators — thick glowing bar */
|
||||||
|
.card-drop-before::before,
|
||||||
|
.card-drop-after::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 4px;
|
||||||
|
background: var(--t-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 12px var(--t-accent), 0 0 4px var(--t-accent);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.card-drop-before::before { left: -6px; }
|
||||||
|
.card-drop-after::after { right: -6px; }
|
||||||
|
|
||||||
|
/* Board card animation */
|
||||||
|
#board > div {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlays */
|
||||||
|
.overlay { transition: opacity 0.15s; }
|
||||||
|
.overlay:not(.open) { opacity: 0; pointer-events: none; }
|
||||||
|
.overlay.open { opacity: 1; }
|
||||||
|
|
||||||
|
/* Theme picker */
|
||||||
|
.theme-picker { position: relative; }
|
||||||
|
.theme-menu { display: none; position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 200; }
|
||||||
|
.theme-picker.open .theme-menu { display: block; }
|
||||||
|
|
||||||
|
/* Buckets */
|
||||||
|
.bucket-tab.active { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
.bucket-tab.drag-over { border-color: var(--t-accent); background: var(--t-raised); }
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
||||||
|
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
|
||||||
|
|
||||||
|
/* Editor */
|
||||||
|
#editor-body { tab-size: 4; resize: none; }
|
||||||
|
#editor-body:focus { outline: none; }
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
#search { backdrop-filter: blur(4px); }
|
||||||
|
#search-input { caret-color: var(--t-accent); }
|
||||||
|
#search-input:focus { outline: none; }
|
||||||
|
.search-hit { scroll-margin: 8px; }
|
||||||
|
.search-hit.active { background: var(--t-raised); }
|
||||||
|
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
|
#ctx-menu .ctx-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
#ctx-menu .ctx-item:hover { background: var(--t-raised); }
|
||||||
|
#ctx-menu .ctx-item.danger { color: var(--t-danger); }
|
||||||
|
#ctx-menu .ctx-item.danger:hover { background: var(--t-danger); color: var(--t-bg); }
|
||||||
|
#ctx-menu .ctx-sep { height: 1px; background: var(--t-border); margin: 2px 0; }
|
||||||
|
|
||||||
|
/* Card links */
|
||||||
|
.card-link { color: var(--t-accent2); cursor: pointer; border-bottom: 1px dashed var(--t-accent2); transition: color 0.1s; }
|
||||||
|
.card-link:hover { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
.card-link.broken { color: var(--t-danger); border-color: var(--t-danger); opacity: 0.6; }
|
||||||
|
|
||||||
|
/* Autocomplete */
|
||||||
|
#link-ac .ac-item { padding: 6px 12px; cursor: pointer; transition: background 0.1s; }
|
||||||
|
#link-ac .ac-item:hover, #link-ac .ac-item.active { background: var(--t-raised); }
|
||||||
|
#link-ac .ac-item .ac-title { font-size: 12px; color: var(--t-bright); }
|
||||||
|
#link-ac .ac-item .ac-bucket { font-size: 9px; color: var(--t-dim); margin-left: 6px; }
|
||||||
|
|
||||||
|
/* Prose (markdown) */
|
||||||
|
.prose { line-height: 1.7; }
|
||||||
|
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.25em 0 0.5em; }
|
||||||
|
.prose h1 { font-size: 1.4em; }
|
||||||
|
.prose h2 { font-size: 1.15em; }
|
||||||
|
.prose h3 { font-size: 1em; }
|
||||||
|
.prose p { margin: 0.6em 0; }
|
||||||
|
.prose a { color: var(--t-accent); text-decoration: underline; }
|
||||||
|
.prose strong { color: var(--t-bright); font-weight: 700; }
|
||||||
|
.prose em { font-style: italic; }
|
||||||
|
.prose code { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.88em; }
|
||||||
|
.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; }
|
||||||
|
.prose pre code { background: none; border: none; padding: 0; font-size: 0.85em; }
|
||||||
|
.prose ul, .prose ol { padding-left: 1.5em; margin: 0.6em 0; }
|
||||||
|
.prose li { margin: 0.25em 0; }
|
||||||
|
.prose li::marker { color: var(--t-dim); }
|
||||||
|
.prose blockquote { border-left: 3px solid var(--t-accent); padding-left: 1em; margin: 0.8em 0; color: var(--t-muted); }
|
||||||
|
.prose hr { border: none; border-top: 1px solid var(--t-border); margin: 1.2em 0; }
|
||||||
|
/* Todo checkboxes */
|
||||||
|
.prose input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--t-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--t-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 8px;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.prose input[type="checkbox"]:checked {
|
||||||
|
background: var(--t-accent);
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.prose input[type="checkbox"]::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 9px;
|
||||||
|
top: 4px;
|
||||||
|
width: 8px;
|
||||||
|
height: 14px;
|
||||||
|
border: solid transparent;
|
||||||
|
border-width: 0 3px 3px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.prose input[type="checkbox"]:checked::after {
|
||||||
|
border-color: var(--t-bg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.prose input[type="checkbox"]:not(:checked):hover::after {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5em; }
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
margin: 0.8em 0;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
.prose img.resizing { outline: 2px solid var(--t-accent); }
|
||||||
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||||
|
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
||||||
|
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../src/bootstrap.php';
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// ── 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]);
|
||||||
|
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['hyprcrd_auth'] = true;
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
if ($route === '/api/auth/login' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$pw = $input['password'] ?? '';
|
||||||
|
if ($pwHash && password_verify($pw, $pwHash)) {
|
||||||
|
$_SESSION['hyprcrd_auth'] = true;
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
} else {
|
||||||
|
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');
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position) VALUES (:title, :body, :color, :bucket_id, :position)')->execute([
|
||||||
|
'title' => $title, 'body' => $input['body'] ?? '', 'color' => $input['color'] ?? '#ffffff', 'bucket_id' => $input['bucket_id'] ?? null, 'position' => $pos,
|
||||||
|
]);
|
||||||
|
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 ($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'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
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', 'files' => array_keys($_FILES), 'err' => $_FILES['file']['error'] ?? 'missing']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/svg+xml' => 'svg'];
|
||||||
|
$ext = $allowed[$file['type']] ?? null;
|
||||||
|
if (!$ext) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'invalid file type: ' . $file['type']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
|
$dest = $uploadDir . '/' . $name;
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'failed to save, check permissions on ' . $uploadDir]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(201);
|
||||||
|
echo json_encode(['url' => 'uploads/' . $name, 'name' => $name]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export — ZIP with JSON dump + uploads
|
||||||
|
if ($route === '/api/export' && $method === 'GET') {
|
||||||
|
$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], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$uploadDir = __DIR__ . '/uploads';
|
||||||
|
if (is_dir($uploadDir)) {
|
||||||
|
foreach (glob($uploadDir . '/*') as $f) {
|
||||||
|
if (is_file($f)) $zip->addFile($f, 'uploads/' . basename($f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
$db->exec('DELETE FROM cards');
|
||||||
|
$db->exec('DELETE FROM buckets');
|
||||||
|
|
||||||
|
// Import buckets
|
||||||
|
$bucketMap = [];
|
||||||
|
foreach ($data['buckets'] as $b) {
|
||||||
|
$oldId = $b['id'];
|
||||||
|
$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>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Silkscreen:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=<?= time() ?>">
|
||||||
|
<script>
|
||||||
|
tailwind.config = { theme: { extend: {
|
||||||
|
fontFamily: { mono: ['JetBrains Mono', 'monospace'], pixel: ['Silkscreen', 'cursive'] },
|
||||||
|
colors: { t: { bg:'var(--t-bg)', surface:'var(--t-surface)', raised:'var(--t-raised)', border:'var(--t-border)', dim:'var(--t-dim)', muted:'var(--t-muted)', text:'var(--t-text)', bright:'var(--t-bright)', accent:'var(--t-accent)', accent2:'var(--t-accent2)', danger:'var(--t-danger)' } }
|
||||||
|
}}}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
const 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' }},
|
||||||
|
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' }},
|
||||||
|
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' }},
|
||||||
|
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' }},
|
||||||
|
paper: { label:'Paper', vars:{ '--t-bg':'#f5f1eb','--t-surface':'#ffffff','--t-raised':'#eae5dd','--t-border':'#d6d0c4','--t-dim':'#a8a08e','--t-muted':'#7a7468','--t-text':'#3d3832','--t-bright':'#1a1714','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0)','--t-overlay':'rgba(245,241,235,0.8)','--t-font-pixel':'"JetBrains Mono", monospace' }},
|
||||||
|
};
|
||||||
|
function applyTheme(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 border-t-danger text-t-danger pr-16 -->
|
||||||
|
<body class="bg-t-bg text-t-text min-h-screen transition-colors duration-200 flex flex-col">
|
||||||
|
|
||||||
|
<header class="flex items-center justify-between px-6 py-4 border-b border-t-border">
|
||||||
|
<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="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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 px-6 pt-4 pb-2 overflow-x-auto" id="bucket-bar"></div>
|
||||||
|
<div class="flex items-center justify-between px-6 pb-2">
|
||||||
|
<div></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">
|
||||||
|
<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"></div>
|
||||||
|
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pb-24"></div>
|
||||||
|
|
||||||
|
<footer class="fixed inset-x-0 bottom-0 z-40 border-t border-t-border bg-t-bg/95 backdrop-blur-sm 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="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 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-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 class="absolute top-4 right-4 flex gap-2 z-10">
|
||||||
|
<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-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 pr-32 leading-relaxed"></h2>
|
||||||
|
<div id="detail-body" class="prose text-sm text-t-text"></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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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="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-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</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="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="flex-1"></div>
|
||||||
|
<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">
|
||||||
|
<textarea id="editor-body" placeholder="> write markdown..." class="flex-1 bg-t-bg text-t-text text-sm p-4 font-mono leading-relaxed placeholder:text-t-dim border-none min-h-full overflow-y-auto"></textarea>
|
||||||
|
<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>document.write('<script src="js/app.js?v=' + Date.now() + '"><\/script>');</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1806
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $pdo = null;
|
||||||
|
|
||||||
|
public static function connect(): PDO
|
||||||
|
{
|
||||||
|
if (self::$pdo === null) {
|
||||||
|
$dir = dirname(DB_PATH);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$pdo = new PDO('sqlite:' . DB_PATH, null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::$pdo->exec('PRAGMA journal_mode = WAL');
|
||||||
|
self::$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
self::migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function migrate(): void
|
||||||
|
{
|
||||||
|
self::$pdo->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
');
|
||||||
|
|
||||||
|
self::$pdo->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
');
|
||||||
|
|
||||||
|
self::$pdo->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL DEFAULT "",
|
||||||
|
color TEXT NOT NULL DEFAULT "#ffffff",
|
||||||
|
bucket_id INTEGER DEFAULT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (bucket_id) REFERENCES buckets(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
');
|
||||||
|
|
||||||
|
// Migrations for existing installs
|
||||||
|
$cols = self::$pdo->query('PRAGMA table_info(cards)')->fetchAll();
|
||||||
|
$colNames = array_column($cols, 'name');
|
||||||
|
|
||||||
|
if (!in_array('bucket_id', $colNames)) {
|
||||||
|
self::$pdo->exec('ALTER TABLE cards ADD COLUMN bucket_id INTEGER DEFAULT NULL REFERENCES buckets(id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
if (!in_array('parent_id', $colNames)) {
|
||||||
|
self::$pdo->exec('ALTER TABLE cards ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES cards(id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/Database.php';
|
||||||
|
|
||||||
|
// Ensure data directory is writable
|
||||||
|
$dataDir = dirname(DB_PATH);
|
||||||
|
if (!is_writable($dataDir) && is_dir($dataDir)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'Data directory is not writable: ' . $dataDir;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user