Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2169c1de1b | ||
|
|
b9bd294c90 | ||
|
|
6c0ca4373b | ||
|
|
9f85e484c4 |
No files matched your search
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# Bump version in config.php + electron/package.json
|
||||
set -e
|
||||
|
||||
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||
echo "Usage: ./bump.sh [--patch|--minor|--major]"
|
||||
echo ""
|
||||
echo " --patch bump patch version (default) 1.3.5 → 1.3.6"
|
||||
echo " --minor bump minor version 1.3.6 → 1.4.0"
|
||||
echo " --major bump major version 1.4.0 → 2.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
case "${1:---patch}" in
|
||||
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||
*) PATCH=$((PATCH + 1)) ;;
|
||||
esac
|
||||
|
||||
VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
|
||||
# config.php
|
||||
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
|
||||
|
||||
# electron/package.json
|
||||
if [ -f electron/package.json ]; then
|
||||
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${VERSION}\"/" electron/package.json
|
||||
fi
|
||||
|
||||
# website footer
|
||||
if [ -f website/index.html ]; then
|
||||
sed -i "s/v${CURRENT}/v${VERSION}/g" website/index.html
|
||||
fi
|
||||
|
||||
echo "${CURRENT} → ${VERSION}"
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.3.3');
|
||||
define('APP_VERSION', '1.4.0');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"disabled": [
|
||||
"logseq-import"
|
||||
"logseq-import",
|
||||
"example-content"
|
||||
]
|
||||
}
|
||||
@@ -1,35 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Build hyprcrd distribution ZIP with version bump + minification
|
||||
# Build hyprcrd distribution ZIP (no version bump — use ./bump.sh first)
|
||||
set -e
|
||||
|
||||
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||
echo "Usage: ./dist.sh [--patch|--minor|--major]"
|
||||
echo "Usage: ./dist.sh"
|
||||
echo ""
|
||||
echo " --patch bump patch version (default) 0.1.1 → 0.1.2"
|
||||
echo " --minor bump minor version 0.1.2 → 0.2.0"
|
||||
echo " --major bump major version 0.2.0 → 1.0.0"
|
||||
echo "Builds Tailwind CSS, minifies JS/CSS, and creates"
|
||||
echo "hyprcrd-{version}.zip ready for deployment."
|
||||
echo ""
|
||||
echo "Builds Tailwind CSS, minifies JS/CSS, bumps version in config.php,"
|
||||
echo "and creates hyprcrd-{version}.zip ready for deployment."
|
||||
echo "To bump the version first: ./bump.sh [--patch|--minor|--major]"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Read current version
|
||||
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
# Bump: --major, --minor, or patch (default)
|
||||
case "${1:-patch}" in
|
||||
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||
*) PATCH=$((PATCH + 1)) ;;
|
||||
esac
|
||||
|
||||
VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
|
||||
echo "Version: ${CURRENT} → ${VERSION}"
|
||||
VERSION=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
||||
echo "Building v${VERSION}"
|
||||
|
||||
DIST="hyprcrd-${VERSION}.zip"
|
||||
STAGE="/tmp/hyprcrd-stage-$$"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.5",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
+10
-4
@@ -119,9 +119,12 @@ select[class*="border-t-accent"] {
|
||||
#tasks > div,
|
||||
#calendar > div,
|
||||
#gallery > div,
|
||||
#dialog > div {
|
||||
#dialog > div,
|
||||
#detail > div,
|
||||
#modal > div {
|
||||
border-radius: var(--ui-radius-modal);
|
||||
box-shadow: var(--ui-shadow-modal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
|
||||
@@ -1075,7 +1078,7 @@ body.anim-slow .board-card.card-pulse-local {
|
||||
margin: 0 !important;
|
||||
margin-left: -1.5em !important;
|
||||
display: flex !important;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 28px;
|
||||
padding: 5px 0;
|
||||
@@ -1086,7 +1089,6 @@ body.anim-slow .board-card.card-pulse-local {
|
||||
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
|
||||
.todo-cb {
|
||||
width: 18px; height: 18px; min-width: 18px;
|
||||
transform: translateY(-9px);
|
||||
border: 2px solid var(--t-border); border-radius: 3px;
|
||||
background: transparent; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
@@ -1139,9 +1141,13 @@ body.anim-slow .board-card.card-pulse-local {
|
||||
#board-preview-body.prose img,
|
||||
#board-preview-body .prose img {
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
#editor-preview .prose img:not([width]),
|
||||
#board-preview-body.prose img:not([width]),
|
||||
#board-preview-body .prose img:not([width]) {
|
||||
width: auto;
|
||||
}
|
||||
.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; }
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolderLine truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
|
||||
+27
-1
@@ -293,8 +293,30 @@ if (str_starts_with($route, '/api/')) {
|
||||
echo json_encode(['ok' => true]); exit;
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
||||
$stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100');
|
||||
$stmt->execute(['id' => $m[1]]);
|
||||
echo json_encode($stmt->fetchAll()); exit;
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
// Snapshot changed fields before update
|
||||
$tracked = ['title', 'body', 'color', 'bucket_id', 'due_date'];
|
||||
$changedTracked = array_intersect($tracked, array_keys($input));
|
||||
if ($changedTracked) {
|
||||
$old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch();
|
||||
if ($old) {
|
||||
$histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)');
|
||||
foreach ($changedTracked as $f) {
|
||||
$oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null;
|
||||
if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); }
|
||||
if ((string)$oldVal !== (string)$newVal) {
|
||||
$histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$fields = []; $params = ['id' => $m[1]];
|
||||
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) {
|
||||
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
|
||||
@@ -900,7 +922,11 @@ if (str_starts_with($route, '/api/')) {
|
||||
}
|
||||
if (strlen($raw) >= 17) {
|
||||
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||
if ($decrypted !== false) file_put_contents($dest, $decrypted);
|
||||
if ($decrypted !== false) {
|
||||
$fileBytes = base64_decode($decrypted);
|
||||
if ($fileBytes !== false) $decrypted = $fileBytes;
|
||||
file_put_contents($dest, $decrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,6 +1292,7 @@ $('md-asset-picker')?.addEventListener('click', async () => {
|
||||
// ── Image resize ──
|
||||
|
||||
document.addEventListener('mousedown', e => {
|
||||
if (e.button !== 0) return;
|
||||
const img = e.target.closest('.prose img');
|
||||
if (!img || img.closest('.md-gallery-item')) return;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
// ── Example content plugin — one-click demo data ──
|
||||
|
||||
const h = window.hyprcrd;
|
||||
const api = h?.api || window._exampleApi;
|
||||
|
||||
const COLORS = {
|
||||
blue: '#38bdf8', purple: '#a78bfa', pink: '#f472b6', red: '#fb7185',
|
||||
green: '#34d399', yellow: '#fbbf24', orange: '#fb923c', slate: '#64748b',
|
||||
};
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
title: 'Architecture RFC',
|
||||
color: COLORS.blue,
|
||||
body: `## System Architecture
|
||||
|
||||
The new microservice layout separates auth, data, and notification layers.
|
||||
|
||||
### Key decisions
|
||||
- PostgreSQL for persistent state
|
||||
- Redis for session cache + pub/sub
|
||||
- gRPC between internal services
|
||||
- REST + WebSocket for client API
|
||||
|
||||
### Open questions
|
||||
- [ ] Rate limiting strategy
|
||||
- [ ] Multi-region failover
|
||||
- [x] Auth token rotation scheme
|
||||
- [x] Database migration tooling
|
||||
|
||||
See also: [[Deploy checklist]]
|
||||
|
||||
#project/api #architecture`,
|
||||
},
|
||||
{
|
||||
title: 'Sprint Planning Q3',
|
||||
color: COLORS.purple,
|
||||
body: `## Goals
|
||||
|
||||
1. Finish API v3 migration
|
||||
2. Launch mobile auth flow
|
||||
3. Load testing at 10k concurrent
|
||||
4. Cut staging→prod deploy time to <5min
|
||||
|
||||
**Deadline:** 2026-07-01
|
||||
**Owner:** @alice
|
||||
|
||||
### Capacity
|
||||
| Team | Points | Allocated |
|
||||
|----------|--------|-----------|
|
||||
| Backend | 34 | 30 |
|
||||
| Frontend | 21 | 18 |
|
||||
| Infra | 13 | 13 |
|
||||
|
||||
#meeting #project`,
|
||||
},
|
||||
{
|
||||
title: 'Auth timeout on mobile',
|
||||
color: COLORS.red,
|
||||
body: `Users on iOS Safari report being logged out after ~3 minutes of inactivity.
|
||||
|
||||
### Repro
|
||||
1. Login on iPhone 15 (Safari 18)
|
||||
2. Switch to another app for 3 min
|
||||
3. Return → "session expired"
|
||||
|
||||
### Investigation
|
||||
- Cookie \`SameSite=Strict\` might be too aggressive
|
||||
- Safari's ITP may be purging the session cookie
|
||||
- Backend session TTL is 24h — not the issue
|
||||
|
||||
### Fix
|
||||
Try \`SameSite=Lax\` + short-lived refresh token.
|
||||
|
||||
#bug #urgent #project/frontend/auth`,
|
||||
},
|
||||
{
|
||||
title: 'Deploy checklist',
|
||||
color: COLORS.green,
|
||||
body: `- [x] Run full test suite
|
||||
- [x] Update CHANGELOG.md
|
||||
- [x] Bump version in config
|
||||
- [ ] Tag release in git
|
||||
- [ ] Build Docker image
|
||||
- [ ] Push to registry
|
||||
- [ ] Roll out to staging
|
||||
- [ ] Smoke test staging
|
||||
- [ ] Promote to production
|
||||
- [ ] Monitor Grafana for 30min
|
||||
|
||||
> Last deploy: 2026-04-08 by @bob
|
||||
> Rollback: \`git revert HEAD && ./deploy.sh\`
|
||||
|
||||
#ops #deploy`,
|
||||
},
|
||||
{
|
||||
title: 'Meeting 2026-04-15 — Retro',
|
||||
color: COLORS.yellow,
|
||||
due_date: '2026-04-15',
|
||||
body: `**Zeit:** 14:00-15:00
|
||||
**Ort:** Room 42
|
||||
**Teilnehmer:** Alice, Bob, Carol
|
||||
|
||||
---
|
||||
|
||||
### What went well
|
||||
- Deploy frequency improved (3x/week → daily)
|
||||
- Zero P1 incidents this sprint
|
||||
- New search feature got good feedback
|
||||
|
||||
### What to improve
|
||||
- PR review turnaround still >24h
|
||||
- Flaky integration tests causing false alarms
|
||||
- Documentation gaps in relay module
|
||||
|
||||
### Action items
|
||||
- [ ] @alice: set up PR review SLA
|
||||
- [ ] @bob: fix top 5 flaky tests
|
||||
- [ ] @carol: write relay onboarding doc
|
||||
|
||||
#meeting #termin #retro`,
|
||||
},
|
||||
{
|
||||
title: 'Reading list',
|
||||
color: COLORS.slate,
|
||||
body: `Papers and articles to read:
|
||||
|
||||
- [Designing Data-Intensive Applications](https://dataintensive.net/) — Ch. 7-9
|
||||
- [The Twelve-Factor App](https://12factor.net/)
|
||||
- \`CAP theorem revisited\` — Brewer 2012
|
||||
- Local-first software — Kleppmann et al.
|
||||
|
||||
### Notes
|
||||
> "The truth is the log. The database is a cache."
|
||||
> — Pat Helland
|
||||
|
||||
#personal #research`,
|
||||
},
|
||||
{
|
||||
title: 'API v3 migration',
|
||||
color: COLORS.blue,
|
||||
body: `### Endpoints to migrate
|
||||
|
||||
| v2 | v3 | Status |
|
||||
|----|-----|--------|
|
||||
| GET /users | GET /api/v3/users | done |
|
||||
| POST /auth | POST /api/v3/auth/token | done |
|
||||
| GET /cards | GET /api/v3/cards | in progress |
|
||||
| WS /stream | WS /api/v3/events | planned |
|
||||
|
||||
### Breaking changes
|
||||
- Auth header: \`Bearer\` instead of \`X-Token\`
|
||||
- Pagination: cursor-based instead of offset
|
||||
- Error format: \`{"error": {"code": ..., "message": ...}}\`
|
||||
|
||||
[[Architecture RFC]]
|
||||
|
||||
#project/api #migration`,
|
||||
},
|
||||
{
|
||||
title: 'Infra cost review',
|
||||
color: COLORS.orange,
|
||||
body: `### Monthly breakdown (April)
|
||||
|
||||
\`\`\`
|
||||
Compute (k8s) €340 ████████░░ 68%
|
||||
Storage (S3) €85 ██░░░░░░░░ 17%
|
||||
Database (RDS) €52 █░░░░░░░░░ 10%
|
||||
CDN + DNS €18 ░░░░░░░░░░ 4%
|
||||
Monitoring €8 ░░░░░░░░░░ 2%
|
||||
─────────────────────────────────────
|
||||
Total €503
|
||||
\`\`\`
|
||||
|
||||
Budget: €600 → **€97 headroom**
|
||||
|
||||
Next action: evaluate spot instances for batch workers.
|
||||
|
||||
#ops #infra #finance`,
|
||||
},
|
||||
{
|
||||
title: 'Birthday Alice',
|
||||
color: COLORS.pink,
|
||||
due_date: '2026-06-12',
|
||||
body: `#geburtstag`,
|
||||
},
|
||||
{
|
||||
title: 'Onboarding: new developer',
|
||||
color: COLORS.green,
|
||||
body: `## First week checklist
|
||||
|
||||
- [ ] Laptop + accounts provisioned
|
||||
- [ ] SSH key added to Gitea
|
||||
- [ ] Access to staging cluster
|
||||
- [x] Slack channels joined
|
||||
- [x] 1:1 with team lead
|
||||
- [ ] Read [[Architecture RFC]]
|
||||
- [ ] First PR: fix a good-first-issue
|
||||
|
||||
### Contacts
|
||||
| Role | Person |
|
||||
|------|--------|
|
||||
| Buddy | @carol |
|
||||
| Team lead | @alice |
|
||||
| Infra | @dave |
|
||||
|
||||
#onboarding #ops`,
|
||||
},
|
||||
{
|
||||
title: 'Competitive analysis',
|
||||
color: COLORS.purple,
|
||||
body: `### Landscape
|
||||
|
||||
| Product | Self-hosted | Offline | E2E encryption |
|
||||
|---------|:-----------:|:-------:|:--------------:|
|
||||
| Notion | no | no | no |
|
||||
| Obsidian| yes | yes | paid |
|
||||
| Logseq | yes | yes | no |
|
||||
| **hyprcrd** | **yes** | **yes** | **yes** |
|
||||
|
||||
### Differentiators
|
||||
- Tag extraction from body (zero-friction categorization)
|
||||
- Relay model: multi-tenant sync without cloud dependency
|
||||
- Plugin system: extensible without forking
|
||||
- CalDAV native (not just ical export)
|
||||
|
||||
#research #strategy`,
|
||||
},
|
||||
{
|
||||
title: 'Security audit notes',
|
||||
color: COLORS.red,
|
||||
body: `## Audit 2026-Q1 findings
|
||||
|
||||
### Critical
|
||||
~~1. Session token in URL parameter~~ → fixed in v1.2.1
|
||||
|
||||
### High
|
||||
2. CSRF token not rotated on privilege change → [[Auth timeout on mobile]]
|
||||
|
||||
### Medium
|
||||
3. Upload directory allows .svg (XSS vector)
|
||||
4. Rate limiter not applied to /api/export
|
||||
|
||||
### Low
|
||||
5. Server version exposed in HTTP headers
|
||||
6. No CSP header on main app
|
||||
|
||||
> Next audit scheduled: 2026-07-15
|
||||
|
||||
#security #audit #urgent`,
|
||||
},
|
||||
{
|
||||
title: 'Design system tokens',
|
||||
color: COLORS.purple,
|
||||
body: `### Color palette
|
||||
|
||||
\`\`\`
|
||||
--t-bg: #0f172a base background
|
||||
--t-surface: #1e293b card/panel surface
|
||||
--t-border: #334155 borders
|
||||
--t-text: #94a3b8 body text
|
||||
--t-bright: #e2e8f0 headlines
|
||||
--t-accent: #38bdf8 primary accent
|
||||
--t-accent2: #a78bfa secondary accent
|
||||
--t-danger: #fb7185 errors/destructive
|
||||
\`\`\`
|
||||
|
||||
### Typography
|
||||
- **UI:** Silkscreen (pixel font)
|
||||
- **Content:** JetBrains Mono
|
||||
- **Sizes:** 9px labels, 11px body, 13px headings
|
||||
|
||||
### Radius scale
|
||||
\`--ui-radius-card\`, \`--ui-radius-modal\`, \`--ui-radius-input\`
|
||||
|
||||
All overridable per theme preset.
|
||||
|
||||
#design #frontend`,
|
||||
},
|
||||
{
|
||||
title: 'Customer feedback digest',
|
||||
color: COLORS.yellow,
|
||||
body: `### Week of 2026-04-07
|
||||
|
||||
**Positive:**
|
||||
- "Search is blazing fast" — 3 mentions
|
||||
- "CalDAV sync just works" — 2 mentions
|
||||
- "Love the keyboard shortcuts" — @user42
|
||||
|
||||
**Requests:**
|
||||
- Mobile app (→ Electron PWA?)
|
||||
- Shared boards without relay (→ collab-buckets?)
|
||||
- Dark mode for relay UI (→ already shipped!)
|
||||
|
||||
**Issues:**
|
||||
- Image upload fails >5MB on shared hosting
|
||||
- Calendar popup overlaps on 13" screens
|
||||
|
||||
#feedback #product`,
|
||||
},
|
||||
{
|
||||
title: 'Database schema notes',
|
||||
color: COLORS.slate,
|
||||
body: `### Core tables
|
||||
|
||||
\`\`\`sql
|
||||
cards (id, title, body, color, tags,
|
||||
bucket_id, parent_id, position,
|
||||
due_date, relay_uid,
|
||||
created_at, updated_at)
|
||||
|
||||
buckets (id, name, color, parent_id,
|
||||
collab_uid, position)
|
||||
|
||||
settings (key, value)
|
||||
\`\`\`
|
||||
|
||||
### Indexes
|
||||
- \`cards.bucket_id\` — bucket filter
|
||||
- \`cards.parent_id\` — group lookup
|
||||
- \`buckets.collab_uid UNIQUE\` — relay sync
|
||||
|
||||
SQLite WAL mode for concurrent reads.
|
||||
|
||||
#architecture #database`,
|
||||
},
|
||||
{
|
||||
title: 'Release notes v1.3',
|
||||
color: COLORS.green,
|
||||
body: `## What's new
|
||||
|
||||
### Features
|
||||
- **Tooltip system** — custom styled tooltips replace all native titles
|
||||
- **Asset picker** — thumbnail grid in the editor toolbar
|
||||
- **Super-bucket split** — click activates, caret opens dropdown
|
||||
- **Watch plugin** — analog clock overlay with SVG watchface
|
||||
- **Card peek** — hover preview with rendered markdown
|
||||
|
||||
### Fixes
|
||||
- Shortcuts no longer fire when typing in inputs
|
||||
- Gallery opens even if API fetch fails
|
||||
- Relay poll suppressed during user actions
|
||||
- Find/replace integrated into search overlay
|
||||
|
||||
### Breaking
|
||||
- All \`title=""\` attributes migrated to \`data-tip=""\`
|
||||
|
||||
#release #changelog`,
|
||||
},
|
||||
{
|
||||
title: '2026-04-12',
|
||||
color: COLORS.slate,
|
||||
body: `## Journal
|
||||
|
||||
Started the day reviewing PRs. Merged the tooltip system and the asset picker.
|
||||
|
||||
Spent the afternoon on the Electron build — static PHP binary works but the WAL/SHM files caused issues. Fixed by deleting stale lock files.
|
||||
|
||||
Had a good session on the watch plugin. The wobble animation on the second hand is a nice touch.
|
||||
|
||||
**Tomorrow:**
|
||||
- [ ] Test relay collab-buckets with two instances
|
||||
- [ ] Write release notes
|
||||
- [ ] Screenshot for website
|
||||
|
||||
#journal`,
|
||||
},
|
||||
{
|
||||
title: 'Backup strategy',
|
||||
color: COLORS.orange,
|
||||
body: `### Current
|
||||
|
||||
Daily cron:
|
||||
\`\`\`bash
|
||||
#!/bin/bash
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
cp data/hyprcrd.db "/backup/hyprcrd-$DATE.db"
|
||||
tar czf "/backup/uploads-$DATE.tar.gz" public/uploads/
|
||||
find /backup -mtime +30 -delete
|
||||
\`\`\`
|
||||
|
||||
### Planned
|
||||
- [ ] Automated export ZIP via API
|
||||
- [ ] Offsite copy to S3-compatible storage
|
||||
- [ ] Test restore monthly
|
||||
- [ ] Document in runbook
|
||||
|
||||
> WAL checkpoint before copy:
|
||||
> \`sqlite3 data/hyprcrd.db "PRAGMA wal_checkpoint(TRUNCATE);"\`
|
||||
|
||||
#ops #backup #infra`,
|
||||
},
|
||||
{
|
||||
title: 'Plugin ideas',
|
||||
color: COLORS.pink,
|
||||
body: `### Brainstorm
|
||||
|
||||
- **Kanban columns** — cards as swimlanes per bucket
|
||||
- **Time tracking** — start/stop timer per card, weekly report
|
||||
- **AI summary** — auto-generate card summaries with local LLM
|
||||
- **RSS reader** — fetch feeds into cards
|
||||
- **Webhook triggers** — POST on card create/update/delete
|
||||
- **PDF export** — render card as styled PDF
|
||||
- **Voice memo** — record audio, attach to card, transcribe
|
||||
|
||||
### Criteria for inclusion
|
||||
1. Does it help *find* information?
|
||||
2. Can it be a drop-in plugin (no core changes)?
|
||||
3. Is it useful without an internet connection?
|
||||
|
||||
#idea #plugins #brainstorm`,
|
||||
},
|
||||
{
|
||||
title: 'Monitoring setup',
|
||||
color: COLORS.green,
|
||||
body: `### Stack
|
||||
|
||||
- **Uptime:** UptimeRobot (free tier, 5min interval)
|
||||
- **Errors:** Sentry self-hosted → \`sentry.internal\`
|
||||
- **Metrics:** Grafana + Prometheus
|
||||
- **Logs:** Loki + promtail on each node
|
||||
|
||||
### Alerts
|
||||
| Metric | Threshold | Channel |
|
||||
|--------|-----------|---------|
|
||||
| HTTP 5xx rate | >1% for 5min | Slack #ops-alerts |
|
||||
| Response time p95 | >2s for 10min | Slack #ops-alerts |
|
||||
| Disk usage | >85% | Email + Slack |
|
||||
| SSL expiry | <14 days | Email |
|
||||
|
||||
Dashboard: \`grafana.internal/d/hyprcrd\`
|
||||
|
||||
#ops #monitoring #infra`,
|
||||
},
|
||||
];
|
||||
|
||||
async function generate() {
|
||||
const bucket = await api('api/buckets', { method: 'POST', body: { name: 'Project Alpha' } });
|
||||
const bid = bucket?.id;
|
||||
if (!bid) return false;
|
||||
await api(`api/buckets/${bid}`, { method: 'PATCH', body: { color: COLORS.blue } });
|
||||
|
||||
for (const c of CARDS) {
|
||||
await api('api/cards', {
|
||||
method: 'POST',
|
||||
body: { title: c.title, body: c.body, color: c.color, bucket_id: bid, due_date: c.due_date || null },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const footer = document.getElementById('main-footer');
|
||||
if (!footer) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = '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';
|
||||
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
|
||||
btn.dataset.tip = 'Create example bucket with demo cards';
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="ph ph-spinner text-sm"></i><span class="font-pixel text-[10px]">creating...</span>';
|
||||
const ok = await generate();
|
||||
if (ok) {
|
||||
btn.innerHTML = '<i class="ph ph-check text-sm"></i><span class="font-pixel text-[10px]">Done</span>';
|
||||
await window._reloadAll?.();
|
||||
} else {
|
||||
btn.innerHTML = '<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Failed</span>';
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
const firstChild = footer.querySelector('div');
|
||||
if (firstChild) firstChild.appendChild(btn);
|
||||
|
||||
h?.registerPlugin({ name: 'example-content', version: '1.0.0' });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "example-content",
|
||||
"version": "1.0.0",
|
||||
"description": "One-click demo content for screenshots and first-time exploration.",
|
||||
"frontend": {
|
||||
"js": ["example-content.js"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# terminal plugin
|
||||
|
||||
In-app command line for controlling hyprcrd with text commands.
|
||||
|
||||
## Toggle
|
||||
|
||||
**Shift+T** opens/closes the terminal. **ESC** closes it from inside.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `help` | list all commands |
|
||||
| `clear` | clear terminal output |
|
||||
| `go <bucket>` | switch bucket (name or: all, current, favorites, journal, unsorted) |
|
||||
| `open <card>` | open card preview (fuzzy title match) |
|
||||
| `edit <card>` | open card in editor |
|
||||
| `new <title>` | create card in active bucket |
|
||||
| `delete <card>` | delete a card |
|
||||
| `tag <card> <tag>` | append tag to card body |
|
||||
| `untag <card> <tag>` | remove tag from card body |
|
||||
| `color <card> <hex>` | change card color |
|
||||
| `move <card> <bucket>` | move card to another bucket |
|
||||
| `search <query>` | open search overlay with query |
|
||||
| `find <text>` | list matching cards inline |
|
||||
| `calendar` | open calendar |
|
||||
| `assets` | open assets gallery |
|
||||
| `export` | download export ZIP |
|
||||
| `sort <mode>` | change sort (manual/newest/oldest/alpha/tag) |
|
||||
| `split` | toggle split preview |
|
||||
| `zen` | toggle zen mode |
|
||||
| `count` | show card and bucket counts |
|
||||
| `tags` | list all tags |
|
||||
| `buckets` | list all buckets |
|
||||
| `version` | show app version |
|
||||
|
||||
## Features
|
||||
|
||||
- **Tab completion** on command names
|
||||
- **History** with arrow up/down
|
||||
- **Fuzzy matching** on card and bucket names
|
||||
- Colored output: accent for success, red for errors, grey for info
|
||||
- Slides up from the bottom, max 45% viewport height
|
||||
- Scrollable output buffer
|
||||
|
||||
## Disabling
|
||||
|
||||
Add `"terminal"` to the `disabled` array in `data/plugins.json`.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "terminal",
|
||||
"version": "1.0.0",
|
||||
"description": "In-app terminal for controlling hyprcrd with text commands. Toggle with Shift+T.",
|
||||
"frontend": {
|
||||
"js": ["terminal.js"],
|
||||
"css": ["terminal.css"],
|
||||
"html": ["terminal.html"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.hterm {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 500;
|
||||
background: var(--t-bg); border-top: 1px solid var(--t-border);
|
||||
max-height: 45vh; display: flex; flex-direction: column;
|
||||
box-shadow: 0 -8px 32px rgba(0,0,0,0.4);
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.hterm.hidden { transform: translateY(100%); opacity: 0; pointer-events: none; }
|
||||
|
||||
#hterm-output {
|
||||
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||
font-size: 12px; line-height: 1.7;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.hterm-line { color: var(--t-text); white-space: pre-wrap; word-break: break-word; }
|
||||
.hterm-line.cmd { color: var(--t-bright); }
|
||||
.hterm-line.cmd::before { content: '> '; color: var(--t-accent); }
|
||||
.hterm-line.ok { color: var(--t-accent); }
|
||||
.hterm-line.err { color: var(--t-danger); }
|
||||
.hterm-line.dim { color: var(--t-dim); }
|
||||
.hterm-line.heading { color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; margin-top: 8px; }
|
||||
|
||||
.hterm-input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 16px; border-top: 1px solid var(--t-border);
|
||||
background: var(--t-surface);
|
||||
}
|
||||
.hterm-prompt {
|
||||
color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||
font-size: 11px; flex-shrink: 0;
|
||||
}
|
||||
#hterm-input {
|
||||
flex: 1; background: transparent; border: none; color: var(--t-bright);
|
||||
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||
font-size: 12px; outline: none;
|
||||
}
|
||||
#hterm-input::placeholder { color: var(--t-dim); }
|
||||
@@ -0,0 +1,7 @@
|
||||
<div id="hterm" class="hterm hidden">
|
||||
<div id="hterm-output"></div>
|
||||
<div class="hterm-input-row">
|
||||
<span class="hterm-prompt">></span>
|
||||
<input type="text" id="hterm-input" autocomplete="off" spellcheck="false" placeholder="type a command...">
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,372 @@
|
||||
// ── Terminal plugin — in-app command line ──
|
||||
|
||||
const h = window.hyprcrd;
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
const panel = $('hterm');
|
||||
const output = $('hterm-output');
|
||||
const input = $('hterm-input');
|
||||
|
||||
let visible = false;
|
||||
let history = [];
|
||||
let historyIdx = -1;
|
||||
|
||||
function toggle() {
|
||||
visible = !visible;
|
||||
panel.classList.toggle('hidden', !visible);
|
||||
if (visible) { input.focus(); scrollBottom(); }
|
||||
else { document.activeElement?.blur(); }
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function print(text, cls = '') {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
|
||||
line.textContent = text;
|
||||
output.appendChild(line);
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
function printHtml(html, cls = '') {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
|
||||
line.innerHTML = html;
|
||||
output.appendChild(line);
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
// ── API helpers ──
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
return h?.api?.(path, opts) || fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
function fuzzy(needle, haystack) {
|
||||
const n = needle.toLowerCase();
|
||||
return haystack.filter(s => s.toLowerCase().includes(n));
|
||||
}
|
||||
|
||||
function findCard(query) {
|
||||
const q = query.toLowerCase();
|
||||
return window.state?.cards?.find(c => c.title.toLowerCase() === q)
|
||||
|| window.state?.cards?.find(c => c.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function findBucket(query) {
|
||||
const q = query.toLowerCase();
|
||||
return window.state?.buckets?.find(b => b.name.toLowerCase() === q)
|
||||
|| window.state?.buckets?.find(b => b.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
// ── Commands ──
|
||||
|
||||
const COMMANDS = {};
|
||||
|
||||
function register(name, desc, fn) {
|
||||
COMMANDS[name] = { desc, fn };
|
||||
}
|
||||
|
||||
register('help', 'show available commands', () => {
|
||||
print('COMMANDS', 'heading');
|
||||
const maxLen = Math.max(...Object.keys(COMMANDS).map(k => k.length));
|
||||
for (const [name, cmd] of Object.entries(COMMANDS)) {
|
||||
print(` ${name.padEnd(maxLen + 2)} ${cmd.desc}`, 'dim');
|
||||
}
|
||||
});
|
||||
|
||||
register('clear', 'clear terminal output', () => {
|
||||
output.innerHTML = '';
|
||||
});
|
||||
|
||||
register('go', 'go <bucket> — switch to bucket', (args) => {
|
||||
const name = args.join(' ');
|
||||
if (!name) { print('usage: go <bucket name>', 'err'); return; }
|
||||
const specials = { all: 'all', current: 'current', favorites: 'favorites', journal: 'journal', unsorted: 'none' };
|
||||
if (specials[name.toLowerCase()]) {
|
||||
window.state.activeBucket = specials[name.toLowerCase()];
|
||||
window._renderBoard?.();
|
||||
print(`switched to ${name}`, 'ok');
|
||||
return;
|
||||
}
|
||||
const b = findBucket(name);
|
||||
if (!b) { print(`bucket not found: ${name}`, 'err'); return; }
|
||||
window.state.activeBucket = b.id;
|
||||
window._renderBoard?.();
|
||||
print(`switched to ${b.name}`, 'ok');
|
||||
});
|
||||
|
||||
register('open', 'open <card> — open card preview', async (args) => {
|
||||
const q = args.join(' ');
|
||||
if (!q) { print('usage: open <card title>', 'err'); return; }
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
window._openDetail?.(c.id);
|
||||
print(`opened: ${c.title}`, 'ok');
|
||||
});
|
||||
|
||||
register('edit', 'edit <card> — open card in editor', async (args) => {
|
||||
const q = args.join(' ');
|
||||
if (!q) { print('usage: edit <card title>', 'err'); return; }
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
window._openModal?.(c);
|
||||
print(`editing: ${c.title}`, 'ok');
|
||||
});
|
||||
|
||||
register('new', 'new <title> — create card in active bucket', async (args) => {
|
||||
const title = args.join(' ');
|
||||
if (!title) { print('usage: new <title>', 'err'); return; }
|
||||
const bucketId = window.state?.activeBucket;
|
||||
const numBucket = Number.isFinite(Number(bucketId)) ? Number(bucketId) : null;
|
||||
const res = await api('api/cards', { method: 'POST', body: { title, body: '', color: '#38bdf8', bucket_id: numBucket } });
|
||||
if (res?.id) {
|
||||
print(`created: ${title} (id: ${res.id})`, 'ok');
|
||||
await window._reloadAll?.();
|
||||
} else {
|
||||
print(res?.error || 'failed', 'err');
|
||||
}
|
||||
});
|
||||
|
||||
register('delete', 'delete <card> — delete a card', async (args) => {
|
||||
const q = args.join(' ');
|
||||
if (!q) { print('usage: delete <card title>', 'err'); return; }
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||
print(`deleted: ${c.title}`, 'ok');
|
||||
await window._reloadAll?.();
|
||||
});
|
||||
|
||||
register('tag', 'tag <card> <tag> — add tag to card', async (args) => {
|
||||
if (args.length < 2) { print('usage: tag <card title> <tag>', 'err'); return; }
|
||||
const tag = args.pop();
|
||||
const q = args.join(' ');
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
const body = (c.body || '') + `\n#${tag}`;
|
||||
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
|
||||
print(`tagged ${c.title} with #${tag}`, 'ok');
|
||||
await window._reloadAll?.();
|
||||
});
|
||||
|
||||
register('untag', 'untag <card> <tag> — remove tag from card', async (args) => {
|
||||
if (args.length < 2) { print('usage: untag <card title> <tag>', 'err'); return; }
|
||||
const tag = args.pop();
|
||||
const q = args.join(' ');
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
const body = (c.body || '').replace(new RegExp(`\\s*#${tag}\\b`, 'g'), '');
|
||||
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
|
||||
print(`removed #${tag} from ${c.title}`, 'ok');
|
||||
await window._reloadAll?.();
|
||||
});
|
||||
|
||||
register('color', 'color <card> <hex> — change card color', async (args) => {
|
||||
if (args.length < 2) { print('usage: color <card title> <#hex>', 'err'); return; }
|
||||
const hex = args.pop();
|
||||
const q = args.join(' ');
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { color: hex } });
|
||||
print(`${c.title} → ${hex}`, 'ok');
|
||||
await window._reloadAll?.();
|
||||
});
|
||||
|
||||
register('move', 'move <card> <bucket> — move card to bucket', async (args) => {
|
||||
if (args.length < 2) { print('usage: move <card title> <bucket name>', 'err'); return; }
|
||||
const bucketName = args.pop();
|
||||
const q = args.join(' ');
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
const b = findBucket(bucketName);
|
||||
if (!b) { print(`bucket not found: ${bucketName}`, 'err'); return; }
|
||||
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { bucket_id: b.id } });
|
||||
print(`moved ${c.title} → ${b.name}`, 'ok');
|
||||
await window._reloadAll?.();
|
||||
});
|
||||
|
||||
register('search', 'search <query> — open search with query', (args) => {
|
||||
const q = args.join(' ');
|
||||
window._closeSearch?.();
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const overlay = document.getElementById('search');
|
||||
if (overlay) overlay.classList.add('open');
|
||||
if (searchInput) { searchInput.value = q; searchInput.dispatchEvent(new Event('input')); searchInput.focus(); }
|
||||
print(`searching: ${q}`, 'ok');
|
||||
});
|
||||
|
||||
register('calendar', 'open calendar', () => { window._openCalendar?.(); print('calendar opened', 'ok'); });
|
||||
register('assets', 'open assets gallery', () => { window._openGallery?.(); print('assets opened', 'ok'); });
|
||||
|
||||
register('export', 'download export ZIP', () => {
|
||||
document.getElementById('export-btn')?.click();
|
||||
print('export started', 'ok');
|
||||
});
|
||||
|
||||
register('sort', 'sort <mode> — manual/newest/oldest/alpha/tag', (args) => {
|
||||
const mode = args[0];
|
||||
if (!mode) { print('usage: sort <manual|newest|oldest|alpha|tag>', 'err'); return; }
|
||||
const sortBtn = document.getElementById('sort-toggle');
|
||||
if (sortBtn) {
|
||||
const label = document.getElementById('sort-label');
|
||||
if (label) label.textContent = mode;
|
||||
window.state.sortMode = mode;
|
||||
window._renderBoard?.();
|
||||
print(`sort: ${mode}`, 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
register('split', 'toggle split preview', () => {
|
||||
document.getElementById('board-split-toggle')?.click();
|
||||
print('split toggled', 'ok');
|
||||
});
|
||||
|
||||
register('zen', 'toggle zen mode on current detail', () => {
|
||||
document.getElementById('detail-zen')?.click();
|
||||
print('zen toggled', 'ok');
|
||||
});
|
||||
|
||||
register('count', 'show card and bucket counts', () => {
|
||||
const cards = window.state?.cards?.length || 0;
|
||||
const buckets = window.state?.buckets?.length || 0;
|
||||
print(`${cards} cards · ${buckets} buckets`, 'ok');
|
||||
});
|
||||
|
||||
register('tags', 'list all tags', () => {
|
||||
const tagSet = new Set();
|
||||
for (const c of window.state?.cards || []) {
|
||||
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
|
||||
}
|
||||
if (!tagSet.size) { print('no tags', 'dim'); return; }
|
||||
const sorted = [...tagSet].sort();
|
||||
print(`${sorted.length} tags:`, 'heading');
|
||||
print(sorted.map(t => '#' + t).join(' '));
|
||||
});
|
||||
|
||||
register('buckets', 'list all buckets', () => {
|
||||
const bs = window.state?.buckets || [];
|
||||
if (!bs.length) { print('no buckets', 'dim'); return; }
|
||||
print(`${bs.length} buckets:`, 'heading');
|
||||
for (const b of bs) print(` ${b.name} (${b.id})${b.parent_id ? ` → child of ${b.parent_id}` : ''}`);
|
||||
});
|
||||
|
||||
register('history', 'history <card> — show edit history', async (args) => {
|
||||
const q = args.join(' ');
|
||||
if (!q) { print('usage: history <card title>', 'err'); return; }
|
||||
const c = findCard(q);
|
||||
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||
let entries;
|
||||
try {
|
||||
const res = await fetch(`api/cards/${c.id}/history`);
|
||||
entries = await res.json();
|
||||
} catch (e) { print('failed to fetch history', 'err'); return; }
|
||||
if (!entries || !entries.length) { print(`${c.title}: no edit history`, 'dim'); return; }
|
||||
print(`${c.title} — ${entries.length} changes:`, 'heading');
|
||||
for (const e of entries) {
|
||||
const old = (e.old_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
|
||||
const nw = (e.new_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
|
||||
const time = e.changed_at?.substring(0, 16) || '?';
|
||||
print(` ${time} ${e.field}: ${old} → ${nw}`, 'dim');
|
||||
}
|
||||
});
|
||||
|
||||
register('find', 'find <text> — list matching cards', (args) => {
|
||||
const q = args.join(' ').toLowerCase();
|
||||
if (!q) { print('usage: find <text>', 'err'); return; }
|
||||
const hits = (window.state?.cards || []).filter(c =>
|
||||
c.title.toLowerCase().includes(q) || (c.body || '').toLowerCase().includes(q) || (c.tags || '').toLowerCase().includes(q)
|
||||
);
|
||||
if (!hits.length) { print('no matches', 'dim'); return; }
|
||||
print(`${hits.length} matches:`, 'heading');
|
||||
for (const c of hits.slice(0, 20)) {
|
||||
print(` ${c.title}${c.tags ? ' ' + (c.tags || '').split(',').map(t => '#' + t).join(' ') : ''}`, 'dim');
|
||||
}
|
||||
if (hits.length > 20) print(` ... and ${hits.length - 20} more`, 'dim');
|
||||
});
|
||||
|
||||
register('version', 'show app version', () => {
|
||||
const v = document.querySelector('[data-app-version]')?.textContent || '?';
|
||||
print(v, 'ok');
|
||||
});
|
||||
|
||||
// ── Tab completion ──
|
||||
|
||||
function tabComplete() {
|
||||
const val = input.value;
|
||||
const parts = val.split(/\s+/);
|
||||
if (parts.length <= 1) {
|
||||
const matches = fuzzy(parts[0] || '', Object.keys(COMMANDS));
|
||||
if (matches.length === 1) { input.value = matches[0] + ' '; }
|
||||
else if (matches.length > 1) { print(matches.join(' '), 'dim'); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Execute ──
|
||||
|
||||
async function exec(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
history.push(trimmed);
|
||||
historyIdx = history.length;
|
||||
print(trimmed, 'cmd');
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const cmd = parts.shift().toLowerCase();
|
||||
const command = COMMANDS[cmd];
|
||||
|
||||
if (!command) {
|
||||
print(`unknown command: ${cmd}. Type "help" for a list.`, 'err');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await command.fn(parts);
|
||||
} catch (e) {
|
||||
print(`error: ${e.message}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ──
|
||||
|
||||
input?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const val = input.value;
|
||||
input.value = '';
|
||||
exec(val);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
tabComplete();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (historyIdx > 0) { historyIdx--; input.value = history[historyIdx]; }
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (historyIdx < history.length - 1) { historyIdx++; input.value = history[historyIdx]; }
|
||||
else { historyIdx = history.length; input.value = ''; }
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
|
||||
// Shift+T toggle (capture, before shortcuts)
|
||||
window.addEventListener('keydown', e => {
|
||||
if (e.key === 'T' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable) return;
|
||||
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||
toggle();
|
||||
}
|
||||
}, { capture: true });
|
||||
|
||||
print('hyprcrd terminal — type "help" for commands, Shift+T to toggle', 'dim');
|
||||
|
||||
h?.registerPlugin({ name: 'terminal', version: '1.0.0' });
|
||||
@@ -171,6 +171,7 @@ export function csrf() {
|
||||
}
|
||||
|
||||
export async function closeAll() {
|
||||
document.getElementById('hterm')?.classList.add('hidden');
|
||||
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el?.classList.contains('open')) el.classList.remove('open');
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
function termToggle() {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'T', shiftKey: true, bubbles: true }));
|
||||
}
|
||||
|
||||
function termExec(cmd) {
|
||||
const input = document.getElementById('hterm-input');
|
||||
if (!input) return;
|
||||
input.value = cmd;
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
}
|
||||
|
||||
function termOutput() {
|
||||
return document.getElementById('hterm-output')?.textContent || '';
|
||||
}
|
||||
|
||||
async function makeCard(title, body = '') {
|
||||
return fetch('api/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
describe('Terminal: Toggle', () => {
|
||||
afterEach(async () => {
|
||||
document.getElementById('hterm')?.classList.add('hidden');
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('panel exists but is hidden', () => {
|
||||
const panel = document.getElementById('hterm');
|
||||
expect(panel).toExist();
|
||||
expect(panel.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('Shift+T opens the terminal', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('Shift+T again closes it', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('ESC closes the terminal', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
const input = document.getElementById('hterm-input');
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await sleep(50);
|
||||
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal: Commands', () => {
|
||||
afterEach(async () => {
|
||||
document.getElementById('hterm')?.classList.add('hidden');
|
||||
document.getElementById('hterm-output').innerHTML = '';
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('help lists commands', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('help');
|
||||
await sleep(50);
|
||||
const out = termOutput();
|
||||
expect(out).toContain('help');
|
||||
expect(out).toContain('new');
|
||||
expect(out).toContain('search');
|
||||
});
|
||||
|
||||
it('count shows numbers', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('count');
|
||||
await sleep(50);
|
||||
expect(termOutput()).toContain('cards');
|
||||
expect(termOutput()).toContain('buckets');
|
||||
});
|
||||
|
||||
it('version shows version string', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('version');
|
||||
await sleep(50);
|
||||
expect(termOutput()).toContain('v');
|
||||
});
|
||||
|
||||
it('unknown command shows error', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('xyznonexistent');
|
||||
await sleep(50);
|
||||
expect(termOutput()).toContain('unknown command');
|
||||
});
|
||||
|
||||
it('tags lists existing tags', async () => {
|
||||
await makeCard('__term_tag_test__', '#alpha #beta');
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('tags');
|
||||
await sleep(50);
|
||||
expect(termOutput()).toContain('#alpha');
|
||||
expect(termOutput()).toContain('#beta');
|
||||
});
|
||||
|
||||
it('find matches cards by title', async () => {
|
||||
await makeCard('__term_find_me__', 'findable body');
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('find __term_find_me__');
|
||||
await sleep(50);
|
||||
expect(termOutput()).toContain('__term_find_me__');
|
||||
});
|
||||
|
||||
it('go switches to bucket', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('go all');
|
||||
await sleep(100);
|
||||
expect(String(state.activeBucket)).toBe('all');
|
||||
});
|
||||
|
||||
it('new creates a card', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('new __term_new_card__');
|
||||
await sleep(400);
|
||||
const out = termOutput();
|
||||
expect(out).toContain('created');
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
expect(cards.some(c => c.title === '__term_new_card__')).toBe(true);
|
||||
});
|
||||
|
||||
it('clear empties output', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
termExec('help');
|
||||
await sleep(50);
|
||||
expect(termOutput().length).toBeGreaterThan(10);
|
||||
termExec('clear');
|
||||
await sleep(50);
|
||||
expect(document.getElementById('hterm-output').children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal: Tab Completion', () => {
|
||||
afterEach(async () => {
|
||||
document.getElementById('hterm')?.classList.add('hidden');
|
||||
document.getElementById('hterm-output').innerHTML = '';
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('Tab completes unique command', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
const input = document.getElementById('hterm-input');
|
||||
input.value = 'vers';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
||||
await sleep(50);
|
||||
expect(input.value).toBe('version ');
|
||||
});
|
||||
|
||||
it('Tab shows multiple matches', async () => {
|
||||
termToggle();
|
||||
await sleep(50);
|
||||
const input = document.getElementById('hterm-input');
|
||||
input.value = 'c';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
||||
await sleep(50);
|
||||
const out = termOutput();
|
||||
expect(out).toContain('clear');
|
||||
expect(out).toContain('count');
|
||||
expect(out).toContain('calendar');
|
||||
expect(out).toContain('color');
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ const FILES = [
|
||||
'./notifications.test.js',
|
||||
'./quick-capture.test.js',
|
||||
'./asset-picker.test.js',
|
||||
'./terminal.test.js',
|
||||
'./theme.test.js',
|
||||
];
|
||||
|
||||
|
||||
@@ -17,8 +17,93 @@
|
||||
|
||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
// $clientTenant is set by messages.php earlier in the chain via authClient().
|
||||
// We re-resolve here to keep this file self-contained.
|
||||
// ── Session-based chat (relay UI) ──
|
||||
|
||||
if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$myId = $sessionTenant['id'];
|
||||
$stmt = $db->prepare('
|
||||
SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
|
||||
FROM chat_messages WHERE recipient_id = :r
|
||||
GROUP BY sender_username
|
||||
UNION
|
||||
SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
|
||||
FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
|
||||
WHERE cm.sender_id = :s AND cm.recipient_id != :s2
|
||||
GROUP BY t.username
|
||||
');
|
||||
$stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$threads = [];
|
||||
foreach ($rows as $row) {
|
||||
$peer = $row['peer'];
|
||||
if (!isset($threads[$peer])) $threads[$peer] = ['peer' => $peer, 'last_at' => $row['last_at'], 'count' => 0];
|
||||
$threads[$peer]['count'] += (int)$row['count'];
|
||||
if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
|
||||
}
|
||||
usort($threads, fn($a, $b) => strcmp($b['last_at'], $a['last_at']));
|
||||
// Add unread count per peer
|
||||
foreach ($threads as &$t) {
|
||||
$stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0');
|
||||
$stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
|
||||
$t['unread'] = (int)$stmt2->fetchColumn();
|
||||
}
|
||||
jsonResponse(array_values($threads));
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
|
||||
$peer = trim($_GET['peer'] ?? '');
|
||||
if (!$peer) jsonResponse(['error' => 'peer required'], 400);
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$myId = $sessionTenant['id'];
|
||||
$peerTenant = getTenantByUsername($db, $peer);
|
||||
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
|
||||
$peerId = $peerTenant['id'];
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, sender_id, sender_username, body, created_at, seen
|
||||
FROM chat_messages
|
||||
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
|
||||
ORDER BY created_at ASC LIMIT 500
|
||||
');
|
||||
$stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'from' => $row['sender_username'],
|
||||
'mine' => (int)$row['sender_id'] === $myId,
|
||||
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||
'created_at' => $row['created_at'],
|
||||
'seen' => (int)$row['seen'],
|
||||
];
|
||||
}
|
||||
// Mark incoming as seen
|
||||
$db->prepare('UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_id = :p AND seen = 0')
|
||||
->execute(['r' => $myId, 'p' => $peerId]);
|
||||
jsonResponse($out);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$body = trim((string)($input['body'] ?? ''));
|
||||
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
||||
$recipient = getTenantByUsername($db, $recipientName);
|
||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$stored = clientEncrypt($body, $interKey);
|
||||
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
||||
->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
|
||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
// ── Client API ──
|
||||
|
||||
$clientTenant = $clientTenant ?? authClient($db);
|
||||
|
||||
if ($route === '/api/chat' && $method === 'POST') {
|
||||
|
||||
@@ -27,6 +27,7 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$storedTitle = clientEncrypt($title, $interKey);
|
||||
$storedBody = clientEncrypt($body, $interKey);
|
||||
$relayUid = $input['relay_uid'] ?? bin2hex(random_bytes(16));
|
||||
|
||||
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
|
||||
->execute([
|
||||
@@ -37,9 +38,43 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
||||
'b' => $storedBody,
|
||||
'c' => $input['color'] ?? '#38bdf8',
|
||||
'tg' => $input['tags'] ?? '',
|
||||
'uid' => $input['relay_uid'] ?? null,
|
||||
'uid' => $relayUid,
|
||||
]);
|
||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName, 'relay_uid' => $relayUid]);
|
||||
}
|
||||
|
||||
// ── UI attachment upload: session stores files encrypted at rest ──
|
||||
|
||||
if ($route === '/api/attachments/upload' && $method === 'POST' && $sessionTenant) {
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
jsonResponse(['error' => 'no file'], 400);
|
||||
}
|
||||
$relayUid = $_POST['relay_uid'] ?? '';
|
||||
$recipientName = trim($_POST['recipient'] ?? $sessionTenant['username']);
|
||||
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
|
||||
|
||||
$recipient = getTenantByUsername($db, $recipientName);
|
||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
|
||||
// Generate hex filename so hyprcrd's poll handler accepts it
|
||||
$origName = basename($_FILES['file']['name']);
|
||||
$ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
|
||||
$hexName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
|
||||
|
||||
$plainBytes = file_get_contents($_FILES['file']['tmp_name']);
|
||||
// Store as base64(raw) encrypted — matches client API format.
|
||||
// The GET endpoint re-encrypts for recipient; hyprcrd decrypts
|
||||
// and base64_decodes to get the original file bytes.
|
||||
$storedData = clientEncrypt(base64_encode($plainBytes), $interKey);
|
||||
|
||||
$db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn')
|
||||
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName]);
|
||||
$db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)')
|
||||
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName, 'data' => $storedData]);
|
||||
jsonResponse(['ok' => true, 'filename' => $hexName, 'original' => $origName]);
|
||||
}
|
||||
|
||||
// ── UI inbox view: session reads decrypted messages ──
|
||||
@@ -71,6 +106,28 @@ if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
|
||||
jsonResponse(['messages' => $out]);
|
||||
}
|
||||
|
||||
// ── UI sent view: messages this user sent to others ──
|
||||
|
||||
if ($route === '/api/messages/sent' && $method === 'GET' && $sessionTenant) {
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$stmt = $db->prepare('SELECT m.id, m.title, m.body, m.created_at, m.fetched, t.username AS recipient FROM messages m JOIN tenants t ON t.id = m.recipient_id WHERE m.sender_id = :s AND m.recipient_id != :s2 ORDER BY m.created_at DESC LIMIT 200');
|
||||
$stmt->execute(['s' => $sessionTenant['id'], 's2' => $sessionTenant['id']]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'recipient' => $row['recipient'] ?? '?',
|
||||
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||
'created_at' => $row['created_at'],
|
||||
'fetched' => (int)$row['fetched'],
|
||||
];
|
||||
}
|
||||
jsonResponse(['messages' => $out]);
|
||||
}
|
||||
|
||||
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
||||
|
||||
$clientTenant = authClient($db);
|
||||
@@ -155,8 +212,8 @@ if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||
$tenant = $clientTenant ?: $sessionTenant;
|
||||
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
$id = (int)$m[1];
|
||||
$db->prepare('DELETE FROM messages WHERE id = :id AND recipient_id = :r')
|
||||
->execute(['id' => $id, 'r' => $tenant['id']]);
|
||||
$db->prepare('DELETE FROM messages WHERE id = :id AND (recipient_id = :r OR sender_id = :s)')
|
||||
->execute(['id' => $id, 'r' => $tenant['id'], 's' => $tenant['id']]);
|
||||
jsonResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
|
||||
+274
-10
@@ -68,14 +68,15 @@ function showView(name) {
|
||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||
name = 'change-password';
|
||||
}
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
|
||||
$('view-' + v)?.classList.add('hidden');
|
||||
});
|
||||
$('view-' + name)?.classList.remove('hidden');
|
||||
|
||||
if (['compose', 'account', 'admin'].includes(name)) {
|
||||
if (['compose', 'account', 'admin', 'chat'].includes(name)) {
|
||||
$('nav-buttons').classList.remove('hidden');
|
||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
||||
$('nav-account').classList.toggle('active', name === 'account');
|
||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||
$('nav-burger')?.classList.remove('hidden');
|
||||
@@ -91,11 +92,18 @@ function showView(name) {
|
||||
if (name === 'compose') {
|
||||
loadTenants().then(() => populateRecipientPicker());
|
||||
loadInbox();
|
||||
loadOutbox();
|
||||
startInboxPoll();
|
||||
$('compose-body')?.focus();
|
||||
} else {
|
||||
stopInboxPoll();
|
||||
}
|
||||
if (name === 'chat') {
|
||||
loadChatThreads();
|
||||
startChatPoll();
|
||||
} else {
|
||||
stopChatPoll();
|
||||
}
|
||||
if (name === 'admin') {
|
||||
loadUsers();
|
||||
} else if (name === 'account') {
|
||||
@@ -118,7 +126,7 @@ function startInboxPoll() {
|
||||
stopInboxPoll();
|
||||
inboxPollHandle = setInterval(() => {
|
||||
if (inboxBusy) return;
|
||||
if (!$('view-compose')?.classList.contains('hidden')) loadInbox({ quiet: true });
|
||||
if (!$('view-compose')?.classList.contains('hidden')) { loadInbox({ quiet: true }); loadOutbox({ quiet: true }); }
|
||||
}, 15000);
|
||||
}
|
||||
function stopInboxPoll() {
|
||||
@@ -333,6 +341,7 @@ function populateRecipientPicker() {
|
||||
hidden.value = opt.dataset.value;
|
||||
label.textContent = opt.textContent.trim();
|
||||
dd.classList.add('hidden');
|
||||
updateCcSelfVisibility();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,6 +354,17 @@ function populateRecipientPicker() {
|
||||
|
||||
// Solo tenant → hide the picker
|
||||
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
|
||||
updateCcSelfVisibility();
|
||||
}
|
||||
|
||||
let composeFiles = [];
|
||||
|
||||
function updateCcSelfVisibility() {
|
||||
const wrap = $('compose-cc-self-wrap');
|
||||
if (!wrap) return;
|
||||
const recipient = $('compose-recipient')?.value || '';
|
||||
const isSelf = !recipient || (me && recipient === me.username);
|
||||
wrap.style.display = isSelf ? 'none' : '';
|
||||
}
|
||||
|
||||
function clearCompose() {
|
||||
@@ -352,27 +372,140 @@ function clearCompose() {
|
||||
$('compose-body').value = '';
|
||||
$('compose-body').focus();
|
||||
setComposeStatus('');
|
||||
composeFiles = [];
|
||||
renderComposeAttachments();
|
||||
}
|
||||
|
||||
$('compose-attach-btn')?.addEventListener('click', () => $('compose-file')?.click());
|
||||
$('compose-file')?.addEventListener('change', e => {
|
||||
for (const f of e.target.files) {
|
||||
if (composeFiles.some(x => x.name === f.name && x.size === f.size)) continue;
|
||||
composeFiles.push(f);
|
||||
}
|
||||
e.target.value = '';
|
||||
renderComposeAttachments();
|
||||
});
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function fileIcon(name) {
|
||||
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) return 'ph-image';
|
||||
if (/\.(mp3|ogg|wav|m4a)$/i.test(name)) return 'ph-music-note';
|
||||
if (/\.pdf$/i.test(name)) return 'ph-file-pdf';
|
||||
return 'ph-file';
|
||||
}
|
||||
|
||||
function renderComposeAttachments() {
|
||||
const el = $('compose-attachments');
|
||||
if (!el) return;
|
||||
el.innerHTML = composeFiles.map((f, i) => `
|
||||
<div class="compose-att">
|
||||
<i class="ph ${fileIcon(f.name)} att-icon"></i>
|
||||
<span>${escapeHtml(f.name)}</span>
|
||||
<span class="att-size">${formatSize(f.size)}</span>
|
||||
<button class="att-remove" data-idx="${i}" aria-label="remove"><i class="ph ph-x"></i></button>
|
||||
</div>
|
||||
`).join('');
|
||||
el.querySelectorAll('.att-remove').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
composeFiles.splice(parseInt(btn.dataset.idx), 1);
|
||||
renderComposeAttachments();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let composeStatusTimer = null;
|
||||
function setComposeStatus(text, kind = '') {
|
||||
clearTimeout(composeStatusTimer);
|
||||
const el = $('compose-status');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = 'status ' + kind;
|
||||
el.className = 'compose-status-bar' + (kind ? ' ' + kind : '');
|
||||
if (text) {
|
||||
el.classList.add('visible');
|
||||
if (kind === 'ok') {
|
||||
composeStatusTimer = setTimeout(() => { el.classList.remove('visible'); }, 4000);
|
||||
}
|
||||
} else {
|
||||
el.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function randomHex(n) {
|
||||
const a = new Uint8Array(n);
|
||||
crypto.getRandomValues(a);
|
||||
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function sendCompose() {
|
||||
const title = $('compose-title').value;
|
||||
const body = $('compose-body').value;
|
||||
let body = $('compose-body').value;
|
||||
const recipient = $('compose-recipient').value;
|
||||
if (!body.trim()) { setComposeStatus('body required', 'err'); return; }
|
||||
if (!body.trim() && !composeFiles.length) { setComposeStatus('body or attachment required', 'err'); return; }
|
||||
|
||||
const relayUid = randomHex(16);
|
||||
const hasFiles = composeFiles.length > 0;
|
||||
|
||||
if (hasFiles) {
|
||||
setComposeStatus('uploading files...');
|
||||
const hexNames = [];
|
||||
for (const f of composeFiles) {
|
||||
const form = new FormData();
|
||||
form.append('file', f);
|
||||
form.append('relay_uid', relayUid);
|
||||
form.append('recipient', recipient || '');
|
||||
try {
|
||||
const r = await fetch('api/attachments/upload', { method: 'POST', body: form });
|
||||
const data = await r.json();
|
||||
if (data.ok && data.filename) {
|
||||
const ext = (data.original || f.name).split('.').pop().toLowerCase();
|
||||
const isImg = /^(png|jpe?g|gif|webp)$/.test(ext);
|
||||
const label = data.original || f.name;
|
||||
hexNames.push(isImg
|
||||
? ``
|
||||
: `[📄 ${label}](uploads/${data.filename})`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (hexNames.length) {
|
||||
body = body ? body + '\n\n' + hexNames.join('\n') : hexNames.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
setComposeStatus('sending...');
|
||||
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } });
|
||||
const ccSelf = $('compose-cc-self')?.checked && recipient;
|
||||
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient, relay_uid: relayUid } });
|
||||
if (res.ok) {
|
||||
setComposeStatus(`sent to ${res.recipient}`, 'ok');
|
||||
// Send a copy to self tagged with recipient name
|
||||
if (ccSelf) {
|
||||
const ccTag = `#sent/${res.recipient}`;
|
||||
const ccBody = body ? body + '\n\n' + ccTag : ccTag;
|
||||
const ccUid = randomHex(16);
|
||||
await api('api/messages', { method: 'POST', body: { title, body: ccBody, relay_uid: ccUid, tags: `sent/${res.recipient}` } });
|
||||
// Also copy attachments to the self-message
|
||||
if (hasFiles) {
|
||||
for (const f of composeFiles) {
|
||||
const form = new FormData();
|
||||
form.append('file', f);
|
||||
form.append('relay_uid', ccUid);
|
||||
form.append('recipient', '');
|
||||
try { await fetch('api/attachments/upload', { method: 'POST', body: form }); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
setComposeStatus(`sent to ${res.recipient}${ccSelf ? ' + copy to self' : ''}${hasFiles ? ` · ${composeFiles.length} file${composeFiles.length !== 1 ? 's' : ''}` : ''}`, 'ok');
|
||||
$('compose-title').value = '';
|
||||
$('compose-body').value = '';
|
||||
$('compose-cc-self').checked = false;
|
||||
composeFiles = [];
|
||||
renderComposeAttachments();
|
||||
$('compose-body').focus();
|
||||
loadInbox();
|
||||
loadOutbox();
|
||||
refreshMe();
|
||||
} else {
|
||||
setComposeStatus(res.error || 'failed', 'err');
|
||||
@@ -426,7 +559,42 @@ async function loadInbox({ quiet = false } = {}) {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function deleteMessage(id, btn) {
|
||||
let lastOutboxJson = '';
|
||||
|
||||
async function loadOutbox({ quiet = false } = {}) {
|
||||
const list = $('outbox-list');
|
||||
if (!list) return;
|
||||
const res = await api('api/messages/sent');
|
||||
const messages = res?.messages || [];
|
||||
|
||||
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
|
||||
if (quiet && json === lastOutboxJson) return;
|
||||
lastOutboxJson = json;
|
||||
|
||||
$('outbox-count').textContent = messages.length;
|
||||
|
||||
if (!messages.length) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="ph ph-paper-plane-tilt icon"></i>
|
||||
<div class="label">no sent messages</div>
|
||||
<div class="hint">Messages you send to other tenants will appear here.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = messages.map(m => `
|
||||
<div class="inbox-item" data-id="${m.id}">
|
||||
<div class="content">
|
||||
<div class="from"><i class="ph ph-arrow-right"></i> to ${escapeHtml(m.recipient)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">pending</span>'}</div>
|
||||
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
|
||||
<button class="del" onclick="deleteMessage(${m.id}, this, 'outbox')" aria-label="delete"><i class="ph ph-trash"></i></button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function deleteMessage(id, btn, source) {
|
||||
inboxBusy = true;
|
||||
// Inline confirmation: replace the trash button with yes/no.
|
||||
// Event listeners are wired via JS (not inline onclick) to avoid
|
||||
@@ -448,7 +616,7 @@ async function deleteMessage(id, btn) {
|
||||
inboxBusy = false;
|
||||
if (res?.ok) {
|
||||
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
|
||||
loadInbox();
|
||||
if (source === 'outbox') loadOutbox(); else loadInbox();
|
||||
refreshMe();
|
||||
toast('message deleted', 'ok');
|
||||
} else {
|
||||
@@ -486,6 +654,102 @@ window.deleteAllMessages = deleteAllMessages;
|
||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||
* or a full date for older messages. */
|
||||
// ── Chat ──
|
||||
|
||||
let chatActivePeer = null;
|
||||
let chatPollHandle = null;
|
||||
|
||||
function startChatPoll() {
|
||||
stopChatPoll();
|
||||
chatPollHandle = setInterval(() => {
|
||||
loadChatThreads(true);
|
||||
if (chatActivePeer) loadChatHistory(chatActivePeer, true);
|
||||
}, 5000);
|
||||
}
|
||||
function stopChatPoll() {
|
||||
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
|
||||
}
|
||||
|
||||
async function loadChatThreads(quiet = false) {
|
||||
const list = $('chat-thread-list');
|
||||
if (!list) return;
|
||||
const threads = await api('api/chat/threads') || [];
|
||||
if (!Array.isArray(threads)) return;
|
||||
|
||||
// If no threads and not quiet, show empty state
|
||||
if (!threads.length && !quiet) {
|
||||
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
|
||||
return;
|
||||
}
|
||||
if (!threads.length) return;
|
||||
|
||||
list.innerHTML = threads.map(t => `
|
||||
<div class="chat-thread ${chatActivePeer === t.peer ? 'active' : ''}" data-peer="${escapeHtml(t.peer)}">
|
||||
<i class="ph ph-user-circle" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="peer-name">${escapeHtml(t.peer)}</div>
|
||||
<div class="thread-time">${formatRelTime(t.last_at)}</div>
|
||||
</div>
|
||||
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll('.chat-thread').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
chatActivePeer = el.dataset.peer;
|
||||
loadChatThreads(true);
|
||||
loadChatHistory(chatActivePeer);
|
||||
$('chat-input-bar')?.classList.remove('hidden');
|
||||
$('chat-peer-name').textContent = chatActivePeer;
|
||||
$('chat-input')?.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChatHistory(peer, quiet = false) {
|
||||
const container = $('chat-messages');
|
||||
if (!container) return;
|
||||
const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || [];
|
||||
if (!Array.isArray(messages)) return;
|
||||
|
||||
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
||||
|
||||
if (!messages.length) {
|
||||
container.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no messages yet</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = messages.map(m => `
|
||||
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
||||
<div>${escapeHtml(m.body)}</div>
|
||||
<div class="msg-time">${formatRelTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
if (wasAtBottom || !quiet) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// Refresh thread list to clear unread badges
|
||||
if (!quiet) loadChatThreads(true);
|
||||
}
|
||||
|
||||
async function sendChatMessage() {
|
||||
const input = $('chat-input');
|
||||
if (!input || !chatActivePeer) return;
|
||||
const body = input.value.trim();
|
||||
if (!body) return;
|
||||
input.value = '';
|
||||
await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } });
|
||||
loadChatHistory(chatActivePeer);
|
||||
loadChatThreads(true);
|
||||
}
|
||||
|
||||
$('chat-send-btn')?.addEventListener('click', sendChatMessage);
|
||||
$('chat-input')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
|
||||
});
|
||||
|
||||
function formatRelTime(sqliteUtc) {
|
||||
if (!sqliteUtc) return '';
|
||||
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
||||
|
||||
+34
-1
@@ -22,6 +22,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<div id="nav-buttons" class="hidden">
|
||||
<span id="nav-user" class="user"></span>
|
||||
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
||||
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
||||
@@ -91,12 +92,18 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<label>body</label>
|
||||
<textarea id="compose-body" placeholder="write something..."></textarea>
|
||||
</div>
|
||||
<div id="compose-attachments" class="compose-attachments"></div>
|
||||
<label class="checkbox" id="compose-cc-self-wrap" style="margin-top:4px;">
|
||||
<input type="checkbox" id="compose-cc-self"> keep a copy (tagged with recipient name)
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
|
||||
<button id="compose-attach-btn" class="btn btn-secondary"><i class="ph ph-paperclip"></i> attach</button>
|
||||
<input type="file" id="compose-file" multiple class="hidden">
|
||||
<span class="spacer"></span>
|
||||
<span id="compose-status" class="status"></span>
|
||||
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
|
||||
</div>
|
||||
<div id="compose-status" class="compose-status-bar"></div>
|
||||
</div>
|
||||
<div id="inbox-section" class="section">
|
||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
||||
@@ -105,6 +112,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
</h2>
|
||||
<div id="inbox-list"></div>
|
||||
</div>
|
||||
<div id="outbox-section" class="section">
|
||||
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
||||
<div id="outbox-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Forced password change (first login) ── -->
|
||||
@@ -193,6 +204,28 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Chat view ── -->
|
||||
<div id="view-chat" class="hidden">
|
||||
<div class="chat-layout">
|
||||
<div class="chat-sidebar">
|
||||
<div class="chat-sidebar-header">
|
||||
<span class="pixel" style="font-size:10px;color:var(--accent);">conversations</span>
|
||||
</div>
|
||||
<div id="chat-thread-list"></div>
|
||||
</div>
|
||||
<div class="chat-main">
|
||||
<div id="chat-header" class="chat-header">
|
||||
<span id="chat-peer-name" class="pixel" style="font-size:11px;color:var(--bright);">select a conversation</span>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages"></div>
|
||||
<div id="chat-input-bar" class="chat-input-bar hidden">
|
||||
<input type="text" id="chat-input" placeholder="type a message..." autocomplete="off">
|
||||
<button id="chat-send-btn" class="btn btn-primary" style="padding:6px 14px;"><i class="ph ph-paper-plane-tilt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Confirm dialog (replaces native confirm()) -->
|
||||
|
||||
@@ -232,10 +232,78 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
#view-compose:not(.hidden) {
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
.compose-attachments {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
||||
}
|
||||
.compose-attachments:empty { display: none; }
|
||||
.compose-att {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 4px 10px; border-radius: var(--radius);
|
||||
background: var(--raised); border: 1px solid var(--border);
|
||||
font-family: var(--pixel); font-size: 9px; color: var(--text);
|
||||
}
|
||||
.compose-att .att-remove {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
padding: 0; font-size: 11px; line-height: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.compose-att .att-remove:hover { color: var(--danger); }
|
||||
.compose-att .att-icon { font-size: 12px; color: var(--dim); }
|
||||
.compose-att .att-size { color: var(--dim); }
|
||||
.compose-card .actions {
|
||||
display:flex; gap:8px; align-items:center;
|
||||
}
|
||||
.compose-card .actions .spacer { flex:1; }
|
||||
/* Chat view */
|
||||
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
|
||||
.chat-sidebar-header { padding: 12px; border-bottom: 1px solid var(--border); }
|
||||
#chat-thread-list { flex: 1; overflow-y: auto; }
|
||||
.chat-thread {
|
||||
display: flex; align-items: center; gap: 8px; padding: 10px 12px;
|
||||
cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s;
|
||||
}
|
||||
.chat-thread:hover { background: var(--raised); }
|
||||
.chat-thread.active { background: var(--raised); border-left: 3px solid var(--accent); }
|
||||
.chat-thread .peer-name { font-family: var(--pixel); font-size: 10px; color: var(--bright); flex: 1; }
|
||||
.chat-thread .unread-badge {
|
||||
font-family: var(--pixel); font-size: 8px; background: var(--accent);
|
||||
color: var(--bg); padding: 1px 6px; border-radius: 8px; min-width: 16px; text-align: center;
|
||||
}
|
||||
.chat-thread .thread-time { font-family: var(--pixel); font-size: 8px; color: var(--dim); }
|
||||
.chat-main { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.chat-header { padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.chat-msg {
|
||||
max-width: 75%; padding: 8px 12px; border-radius: var(--radius-lg);
|
||||
font-size: 12px; line-height: 1.6; word-break: break-word;
|
||||
}
|
||||
.chat-msg.mine { align-self: flex-end; background: var(--accent); color: var(--bg); border-bottom-right-radius: 2px; }
|
||||
.chat-msg.theirs { align-self: flex-start; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 2px; }
|
||||
.chat-msg .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; }
|
||||
.chat-msg.mine .msg-time { text-align: right; }
|
||||
.chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||
.chat-input-bar input {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--bright);
|
||||
padding: 8px 12px; font-size: 12px; font-family: var(--mono);
|
||||
border-radius: var(--radius); outline: none;
|
||||
}
|
||||
.chat-input-bar input:focus { border-color: var(--accent); }
|
||||
.chat-empty {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 8px; color: var(--dim);
|
||||
font-family: var(--pixel); font-size: 10px;
|
||||
}
|
||||
|
||||
.compose-status-bar {
|
||||
font-family: var(--pixel); font-size: 10px; color: var(--muted);
|
||||
min-height: 0; overflow: hidden;
|
||||
transition: opacity 0.3s ease, max-height 0.3s ease;
|
||||
max-height: 0; opacity: 0;
|
||||
}
|
||||
.compose-status-bar.visible { max-height: 32px; opacity: 1; padding-top: 8px; }
|
||||
.compose-status-bar.ok { color: var(--accent); }
|
||||
.compose-status-bar.err { color: var(--danger); }
|
||||
|
||||
/* Status text (used in compose, account, create-user) */
|
||||
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
|
||||
@@ -564,6 +632,22 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
||||
}
|
||||
#view-admin .section h2 { margin-bottom:10px; }
|
||||
#view-admin .section .create-user-form { margin-top:14px; }
|
||||
|
||||
.chat-layout { flex-direction: column; height: calc(100vh - 160px); }
|
||||
.chat-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: none; }
|
||||
#chat-thread-list {
|
||||
display: flex; overflow-x: auto; overflow-y: hidden; gap: 0;
|
||||
scrollbar-width: none; -ms-overflow-style: none;
|
||||
}
|
||||
#chat-thread-list::-webkit-scrollbar { display: none; }
|
||||
.chat-thread {
|
||||
flex-shrink: 0; flex-direction: column; align-items: center;
|
||||
gap: 2px; padding: 8px 14px; border-bottom: none; border-right: 1px solid var(--border);
|
||||
min-width: 70px; text-align: center;
|
||||
}
|
||||
.chat-thread.active { border-left: none; border-bottom: 3px solid var(--accent); }
|
||||
.chat-thread .thread-time { display: none; }
|
||||
.chat-sidebar-header { display: none; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -151,5 +151,19 @@ class Database
|
||||
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
|
||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
|
||||
}
|
||||
|
||||
// Card edit history
|
||||
self::$pdo->exec('
|
||||
CREATE TABLE IF NOT EXISTS card_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
card_id INTEGER NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||
)
|
||||
');
|
||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_card_history_card ON card_history(card_id, changed_at DESC)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>hyprcrd</title>
|
||||
<meta name="description" content="Self-hosted card manager. Markdown, tags, wiki-links, calendar, encrypted relay. Find everything.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Silkscreen&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #06080f;
|
||||
--s1: #0d1117;
|
||||
--s2: #161b22;
|
||||
--bdr: #21262d;
|
||||
--tx: #8b949e;
|
||||
--hi: #e6edf3;
|
||||
--ac: #38bdf8;
|
||||
--ac2: #a78bfa;
|
||||
--rd: #fb7185;
|
||||
--gn: #34d399;
|
||||
--yl: #fbbf24;
|
||||
--pk: #f472b6;
|
||||
--pixel: 'Silkscreen', monospace;
|
||||
--mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: var(--mono); background: var(--bg); color: var(--tx); }
|
||||
::selection { background: rgba(56,189,248,0.25); }
|
||||
|
||||
/* ── Top bar ── */
|
||||
header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: rgba(6,8,15,0.88); backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--bdr); padding: 0 48px;
|
||||
}
|
||||
header .inner {
|
||||
display: flex; align-items: center; height: 48px; gap: 20px;
|
||||
}
|
||||
header .brand { font-family: var(--pixel); font-size: 12px; color: var(--ac); letter-spacing: 2px; }
|
||||
header nav { margin-left: auto; display: flex; gap: 20px; align-items: center; }
|
||||
header nav a { font-size: 11px; color: var(--tx); font-family: var(--pixel); text-decoration: none; }
|
||||
header nav a:hover { color: var(--hi); }
|
||||
.gh-btn {
|
||||
font-family: var(--pixel); font-size: 10px; color: var(--bg);
|
||||
background: var(--ac); padding: 5px 16px; border-radius: 4px;
|
||||
}
|
||||
.gh-btn:hover { background: #7dd3fc; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
min-height: 100vh; padding: 80px 48px;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
border-bottom: 1px solid var(--bdr);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Section label — big watermark word ── */
|
||||
.section-label {
|
||||
font-family: var(--pixel); font-size: clamp(60px, 12vw, 140px);
|
||||
color: var(--bdr); letter-spacing: 4px; text-transform: uppercase;
|
||||
line-height: 1; margin-bottom: 48px; user-select: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.section-label .accent { color: rgba(56,189,248,0.15); }
|
||||
|
||||
/* ── Hero ── */
|
||||
.hero { padding-top: 100px; }
|
||||
.hero-brand {
|
||||
display: flex; align-items: center; gap: clamp(16px, 3vw, 32px);
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.hero-brand i {
|
||||
font-size: clamp(60px, 12vw, 140px); color: var(--ac);
|
||||
line-height: 1; display: block;
|
||||
}
|
||||
.hero-brand span {
|
||||
font-family: var(--pixel); font-size: clamp(60px, 12vw, 140px);
|
||||
color: var(--ac); letter-spacing: 4px; line-height: 1;
|
||||
}
|
||||
.hero-brand .typed { display: inline; }
|
||||
.hero-brand .cursor {
|
||||
display: inline-block; width: 0.55em; height: 1em;
|
||||
background: var(--ac); vertical-align: text-bottom;
|
||||
margin-left: 2px;
|
||||
animation: cursor-blink 530ms steps(1) infinite;
|
||||
}
|
||||
.cursor-done { animation: cursor-blink 530ms steps(1) 3 forwards !important; }
|
||||
@keyframes cursor-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
.hero-title {
|
||||
font-size: clamp(48px, 8vw, 100px); font-weight: 700;
|
||||
color: var(--hi); line-height: 1.05; margin-bottom: 32px; letter-spacing: -3px;
|
||||
}
|
||||
.hero-title .accent { color: var(--ac); }
|
||||
.hero-title .dim { color: var(--bdr); }
|
||||
.hero-sub { font-size: 16px; line-height: 1.8; max-width: 560px; margin-bottom: 48px; }
|
||||
.hero-terminal {
|
||||
background: var(--s1); border: 1px solid var(--bdr); border-radius: 8px;
|
||||
padding: 20px 28px; font-size: 13px; line-height: 2.2; display: inline-block;
|
||||
}
|
||||
.hero-terminal .p { color: var(--gn); }
|
||||
.hero-terminal .c { color: var(--hi); }
|
||||
.hero-terminal .d { opacity: 0.3; }
|
||||
|
||||
/* ── Feature layout ── */
|
||||
.feat {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 80px;
|
||||
width: 100%; align-items: center;
|
||||
}
|
||||
.feat.reverse .feat-text { order: 2; }
|
||||
.feat.reverse .feat-visual { order: 1; }
|
||||
|
||||
.feat-text h3 {
|
||||
font-size: 28px; color: var(--hi); font-weight: 700;
|
||||
line-height: 1.3; margin-bottom: 20px; letter-spacing: -0.5px;
|
||||
}
|
||||
.feat-text p { font-size: 14px; line-height: 1.9; margin-bottom: 16px; }
|
||||
.feat-text ul { list-style: none; margin-top: 16px; }
|
||||
.feat-text li {
|
||||
font-size: 13px; padding: 6px 0; display: flex; align-items: flex-start; gap: 10px; line-height: 1.6;
|
||||
}
|
||||
.feat-text li::before {
|
||||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--ac); flex-shrink: 0; margin-top: 8px;
|
||||
}
|
||||
|
||||
.feat-visual {
|
||||
background: var(--s1); border: 1px solid var(--bdr); border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Visual panels ── */
|
||||
.vis-code {
|
||||
padding: 28px 32px; font-size: 13px; line-height: 2; white-space: pre-wrap;
|
||||
}
|
||||
.vis-code .kw { color: var(--ac); }
|
||||
.vis-code .str { color: var(--gn); }
|
||||
.vis-code .tag { color: var(--yl); }
|
||||
.vis-code .link { color: var(--ac2); }
|
||||
.vis-code .cm { opacity: 0.3; }
|
||||
.vis-code .hi { color: var(--hi); }
|
||||
|
||||
.vis-tags { padding: 32px; display: flex; flex-wrap: wrap; gap: 10px; align-content: flex-start; }
|
||||
.vtag {
|
||||
font-family: var(--pixel); font-size: 11px; padding: 8px 18px;
|
||||
border-radius: 6px; border: 1px solid;
|
||||
}
|
||||
|
||||
.vis-keys { padding: 32px; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.vkey {
|
||||
font-family: var(--pixel); font-size: 12px; padding: 10px 16px;
|
||||
background: var(--bg); border: 1px solid var(--bdr); border-radius: 6px;
|
||||
color: var(--hi); min-width: 40px; text-align: center;
|
||||
box-shadow: 0 3px 0 var(--bdr);
|
||||
}
|
||||
|
||||
.vis-cards { padding: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.vcard {
|
||||
background: var(--bg); border: 1px solid var(--bdr); border-radius: 6px;
|
||||
padding: 16px; border-top: 3px solid;
|
||||
}
|
||||
.vcard-t { font-family: var(--pixel); font-size: 10px; color: var(--hi); margin-bottom: 8px; }
|
||||
|
||||
.vis-gitea { padding: 28px 32px; }
|
||||
.vis-gitea .ghdr {
|
||||
font-family: var(--pixel); font-size: 10px; color: var(--tx);
|
||||
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.vis-gitea .issue-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 0; border-bottom: 1px solid var(--bdr); font-size: 12px;
|
||||
}
|
||||
.vis-gitea .issue-row:last-child { border-bottom: none; }
|
||||
.vis-gitea .issue-state { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.vis-gitea .issue-title { color: var(--hi); flex: 1; }
|
||||
.vis-gitea .issue-labels { display: flex; gap: 4px; }
|
||||
.vis-gitea .issue-label {
|
||||
font-family: var(--pixel); font-size: 8px; padding: 2px 8px; border-radius: 3px;
|
||||
}
|
||||
.vis-gitea .issue-num { font-family: var(--pixel); font-size: 10px; color: var(--tx); }
|
||||
|
||||
.vis-relay { padding: 28px 32px; font-size: 12px; line-height: 2.2; }
|
||||
.vis-relay .node {
|
||||
display: inline-block; padding: 4px 14px; border-radius: 4px;
|
||||
font-family: var(--pixel); font-size: 10px; border: 1px solid; margin: 2px;
|
||||
}
|
||||
.vis-relay .arrow { color: var(--bdr); margin: 0 6px; }
|
||||
|
||||
/* ── Screenshot ── */
|
||||
.hero-screenshot {
|
||||
margin-top: 56px; border-radius: 10px; overflow: hidden;
|
||||
border: 1px solid var(--bdr);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.03);
|
||||
}
|
||||
.hero-screenshot img { display: block; width: 100%; height: auto; }
|
||||
|
||||
/* ── Email visual ── */
|
||||
.vis-inbox { padding: 24px 32px; }
|
||||
.vis-inbox .inbox-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--bdr); font-size: 12px;
|
||||
}
|
||||
.vis-inbox .inbox-row:last-child { border-bottom: none; }
|
||||
.vis-inbox .inbox-from { width: 120px; color: var(--hi); flex-shrink: 0; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vis-inbox .inbox-subject { flex: 1; color: var(--tx); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vis-inbox .inbox-badge {
|
||||
font-family: var(--pixel); font-size: 8px; padding: 2px 8px;
|
||||
border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
.vis-inbox .inbox-row.spam { opacity: 0.4; }
|
||||
.vis-inbox .inbox-row.spam .inbox-from { text-decoration: line-through; }
|
||||
|
||||
/* ── CTA ── */
|
||||
.cta-section {
|
||||
min-height: 60vh; display: flex; align-items: center; justify-content: center;
|
||||
text-align: center; padding: 80px 48px;
|
||||
}
|
||||
.cta-section h2 {
|
||||
font-family: var(--pixel); font-size: clamp(24px, 4vw, 48px);
|
||||
color: var(--hi); margin-bottom: 16px; letter-spacing: -1px;
|
||||
}
|
||||
.cta-section p { font-size: 14px; margin-bottom: 40px; max-width: 440px; margin-left: auto; margin-right: auto; }
|
||||
.cta-links { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
||||
.cta-btn {
|
||||
font-family: var(--pixel); font-size: 11px; padding: 12px 28px;
|
||||
border-radius: 6px; transition: all 0.2s; text-decoration: none;
|
||||
}
|
||||
.cta-btn.primary { background: var(--ac); color: var(--bg); }
|
||||
.cta-btn.primary:hover { background: #7dd3fc; }
|
||||
.cta-btn.outline { border: 1px solid var(--bdr); color: var(--tx); }
|
||||
.cta-btn.outline:hover { border-color: var(--ac); color: var(--ac); }
|
||||
|
||||
footer {
|
||||
padding: 24px 48px; border-top: 1px solid var(--bdr);
|
||||
font-family: var(--pixel); font-size: 9px; color: var(--tx); opacity: 0.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.section { padding: 60px 24px; }
|
||||
.feat { grid-template-columns: 1fr; gap: 40px; }
|
||||
.feat.reverse .feat-text, .feat.reverse .feat-visual { order: unset; }
|
||||
.section-label { font-size: clamp(36px, 10vw, 80px); }
|
||||
header { padding: 0 20px; }
|
||||
header nav a:not(.gh-btn) { display: none; }
|
||||
footer { padding: 24px; }
|
||||
.cta-section { padding: 60px 24px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="inner">
|
||||
<span class="brand">hyprcrd</span>
|
||||
<nav>
|
||||
<a href="#search">search</a>
|
||||
<a href="#tags">tags</a>
|
||||
<a href="#editor">editor</a>
|
||||
<a href="#gitea">gitea</a>
|
||||
<a href="#relay">relay</a>
|
||||
<a href="#email">email</a>
|
||||
<a href="#deploy">deploy</a>
|
||||
<a href="https://git.uesome.de/tanztee/hyprcrd" class="gh-btn">source</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="section hero">
|
||||
<div class="hero-brand">
|
||||
<i class="ph ph-terminal-window"></i>
|
||||
<span><span class="typed" id="hero-typed"></span><span class="cursor" id="hero-cursor"></span></span>
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
stop losing<br>
|
||||
<span class="accent">information</span><span class="dim">.</span>
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
Cards. Tags. Wiki-links. Markdown. Calendar. Encrypted sync between devices.
|
||||
One SQLite file. Zero cloud. Built for finding things, not filing them.
|
||||
</p>
|
||||
<div class="hero-terminal">
|
||||
<span class="p">$</span> <span class="c">git clone https://git.uesome.de/tanztee/hyprcrd</span><br>
|
||||
<span class="p">$</span> <span class="c">cd hyprcrd && docker compose up -d</span><br>
|
||||
<span class="d"># open localhost:8088 — set password — done</span>
|
||||
</div>
|
||||
<div class="hero-screenshot">
|
||||
<img src="screenshot.png" alt="hyprcrd board view with cards, tags, and buckets">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="search">
|
||||
<div class="section-label">SEARCH</div>
|
||||
<div class="feat">
|
||||
<div class="feat-text">
|
||||
<h3>Type to find. Instantly.</h3>
|
||||
<p>Full-text search hits titles, card bodies, and tags at the same time. Combine <code>#tag</code> filters with free text — <code>#project deploy script</code> finds exactly what you think it does.</p>
|
||||
<ul>
|
||||
<li>Search + replace across every card in the database</li>
|
||||
<li><code>#tag1 #tag2</code> for AND filtering</li>
|
||||
<li>Scope to a specific bucket</li>
|
||||
<li>Drag search results onto each other to group cards</li>
|
||||
<li>Preview panel shows rendered markdown</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-code"><span class="cm">></span> <span class="hi">#project deploy</span>
|
||||
|
||||
<span class="cm"> 3 results in 2ms</span>
|
||||
|
||||
<span class="kw">API redesign</span>
|
||||
<span class="cm">...the <mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark> script handles
|
||||
staging and prod environments...</span>
|
||||
|
||||
<span class="kw">Infra checklist</span>
|
||||
<span class="cm">...<mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark> to k8s cluster
|
||||
after running integration tests...</span>
|
||||
|
||||
<span class="kw">Sprint retro</span>
|
||||
<span class="cm">...discussed <mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark>
|
||||
frequency and rollback strategy...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="tags">
|
||||
<div class="section-label">TAGS</div>
|
||||
<div class="feat reverse">
|
||||
<div class="feat-text">
|
||||
<h3>Write a hashtag.<br>It becomes structure.</h3>
|
||||
<p>No separate tag dialog. No filing step. Write <code>#project</code> in the card body and it extracts automatically into a clickable, filterable tag.</p>
|
||||
<ul>
|
||||
<li>Hierarchical: <code>#project/frontend/auth</code> — each segment is independently clickable</li>
|
||||
<li>Click to filter, Ctrl+click to combine (AND logic)</li>
|
||||
<li>Tag quick-pick with <kbd>T</kbd> — searchable overlay of all existing tags</li>
|
||||
<li>Assign via right-click context menu</li>
|
||||
<li>Super-buckets: nest buckets into collapsible groups</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-tags">
|
||||
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#project</span>
|
||||
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/api</span>
|
||||
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/frontend</span>
|
||||
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/frontend/auth</span>
|
||||
<span class="vtag" style="background:rgba(251,113,133,0.06);color:var(--rd);border-color:rgba(251,113,133,0.2);">#bug</span>
|
||||
<span class="vtag" style="background:rgba(251,113,133,0.06);color:var(--rd);border-color:rgba(251,113,133,0.2);">#urgent</span>
|
||||
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#ops</span>
|
||||
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#done</span>
|
||||
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#meeting</span>
|
||||
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#termin</span>
|
||||
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#research</span>
|
||||
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#idea</span>
|
||||
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#geburtstag</span>
|
||||
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#personal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="editor">
|
||||
<div class="section-label">EDITOR</div>
|
||||
<div class="feat">
|
||||
<div class="feat-text">
|
||||
<h3>Markdown that does more.</h3>
|
||||
<p>Every card is a markdown document with live preview. Enriched with tools that make it a knowledge system, not just a text file.</p>
|
||||
<ul>
|
||||
<li>Wiki-links: <code>[[Card Title]]</code> — auto-complete, backlinks, split view</li>
|
||||
<li>Snippets: <code>?dat</code> <code>?table</code> <code>?code</code> <code>?todo</code></li>
|
||||
<li>Inline calculator: <code>?3+5</code> space → 8</li>
|
||||
<li>Line operations: Alt+Up/Down to move, Shift+Alt to duplicate</li>
|
||||
<li>Upload images, audio, PDFs — paste or drag</li>
|
||||
<li>Asset picker: thumbnail grid with search</li>
|
||||
<li>Extract selection to new card with auto-link</li>
|
||||
<li>Find & replace within the card (Ctrl+H)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-code"><span class="hi">## Sprint planning Q3</span>
|
||||
|
||||
<span class="cm">Goals for next quarter:</span>
|
||||
|
||||
- [x] Finish API redesign
|
||||
- [x] Migrate auth to <span class="link">[[Architecture RFC]]</span>
|
||||
- [ ] Load testing
|
||||
- [ ] Deploy to prod
|
||||
|
||||
<span class="kw">**Deadline:**</span> 2026-07-01
|
||||
<span class="kw">**Owner:**</span> @alice
|
||||
|
||||
See also: <span class="link">[[Infra checklist]]</span>
|
||||
|
||||
<span class="tag">#project/api</span> <span class="tag">#meeting</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="gitea">
|
||||
<div class="section-label"><span class="accent">GITEA</span></div>
|
||||
<div class="feat reverse">
|
||||
<div class="feat-text">
|
||||
<h3>Cards become tickets.<br>Tickets stay linked.</h3>
|
||||
<p>Connect any number of Gitea instances. Browse issues, create tickets directly from cards, and keep your project management in sync without leaving hyprcrd.</p>
|
||||
<ul>
|
||||
<li>Multi-account: connect unlimited Gitea servers with token auth</li>
|
||||
<li>Default repo per account — pre-fills create form, stays editable</li>
|
||||
<li>Right-click any card → <em>Make ticket</em>: title + body pre-fill the issue form</li>
|
||||
<li>Full issue browser: filter by state, scope (assigned/created/mentioned)</li>
|
||||
<li>Label picker, milestone selector, assignee multi-select</li>
|
||||
<li>Repo autocomplete with fuzzy search across all repos</li>
|
||||
<li>Inline issue detail: view body, labels, assignees, edit title, toggle state</li>
|
||||
<li>Pagination for large repos</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-gitea">
|
||||
<div class="ghdr">
|
||||
<span style="color:var(--ac);">git.example.com</span>
|
||||
<span style="opacity:0.3">/</span>
|
||||
<span style="color:var(--hi);">team/hyprcrd</span>
|
||||
<span style="margin-left:auto;padding:2px 10px;border-radius:3px;background:rgba(52,211,153,0.08);color:var(--gn);border:1px solid rgba(52,211,153,0.15);font-family:var(--pixel);">open</span>
|
||||
</div>
|
||||
<div class="issue-row">
|
||||
<span class="issue-state" style="background:var(--gn);"></span>
|
||||
<span class="issue-title">API redesign: migrate to v3 endpoints</span>
|
||||
<span class="issue-labels">
|
||||
<span class="issue-label" style="background:rgba(56,189,248,0.1);color:var(--ac);">enhancement</span>
|
||||
<span class="issue-label" style="background:rgba(167,139,250,0.1);color:var(--ac2);">api</span>
|
||||
</span>
|
||||
<span class="issue-num">#142</span>
|
||||
</div>
|
||||
<div class="issue-row">
|
||||
<span class="issue-state" style="background:var(--gn);"></span>
|
||||
<span class="issue-title">Auth timeout on mobile devices</span>
|
||||
<span class="issue-labels">
|
||||
<span class="issue-label" style="background:rgba(251,113,133,0.1);color:var(--rd);">bug</span>
|
||||
<span class="issue-label" style="background:rgba(251,191,36,0.1);color:var(--yl);">priority</span>
|
||||
</span>
|
||||
<span class="issue-num">#139</span>
|
||||
</div>
|
||||
<div class="issue-row">
|
||||
<span class="issue-state" style="background:var(--gn);"></span>
|
||||
<span class="issue-title">Add CalDAV recurring event support</span>
|
||||
<span class="issue-labels">
|
||||
<span class="issue-label" style="background:rgba(52,211,153,0.1);color:var(--gn);">feature</span>
|
||||
</span>
|
||||
<span class="issue-num">#137</span>
|
||||
</div>
|
||||
<div class="issue-row">
|
||||
<span class="issue-state" style="background:var(--ac2);"></span>
|
||||
<span class="issue-title">Relay: per-pair key exchange for strict E2E</span>
|
||||
<span class="issue-labels">
|
||||
<span class="issue-label" style="background:rgba(244,114,182,0.1);color:var(--pk);">security</span>
|
||||
<span class="issue-label" style="background:rgba(167,139,250,0.1);color:var(--ac2);">relay</span>
|
||||
</span>
|
||||
<span class="issue-num">#131</span>
|
||||
</div>
|
||||
<div class="issue-row">
|
||||
<span class="issue-state" style="background:var(--tx);opacity:0.3;"></span>
|
||||
<span class="issue-title" style="opacity:0.4;">Export: include plugin settings in backup</span>
|
||||
<span class="issue-labels">
|
||||
<span class="issue-label" style="background:rgba(56,189,248,0.05);color:var(--tx);opacity:0.5;">enhancement</span>
|
||||
</span>
|
||||
<span class="issue-num" style="opacity:0.4;">#128</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="relay">
|
||||
<div class="section-label">RELAY</div>
|
||||
<div class="feat">
|
||||
<div class="feat-text">
|
||||
<h3>Sync between devices.<br>Share with people.</h3>
|
||||
<p>A self-hosted message relay that connects multiple hyprcrd instances. Per-user encryption. Collaborative buckets with exclusive card locking. Encrypted chat with read receipts.</p>
|
||||
<ul>
|
||||
<li>Multi-tenant: each user gets their own login + encryption secret</li>
|
||||
<li>Cards sync between devices — push from one, poll from another</li>
|
||||
<li>Collaborative buckets: shared editing with exclusive locks</li>
|
||||
<li>Chat: encrypted messages with delivery + read receipts</li>
|
||||
<li>Compose + inbox UI works on mobile</li>
|
||||
<li>24h ephemeral — the relay routes, it doesn't archive</li>
|
||||
<li>AES-256-CBC at every hop, bcrypt passwords, master key at rest</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-relay">
|
||||
<div style="margin-bottom:20px;">
|
||||
<span class="node" style="border-color:var(--ac);color:var(--ac);">alice</span>
|
||||
<span class="arrow">→</span>
|
||||
<span style="font-family:var(--pixel);font-size:10px;color:var(--tx);">encrypts with own secret</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="node" style="border-color:var(--yl);color:var(--yl);">relay</span>
|
||||
</div>
|
||||
<div style="margin-bottom:20px;padding-left:24px;border-left:2px solid var(--bdr);">
|
||||
<span style="font-size:11px;color:var(--tx);">
|
||||
1. authenticate sender<br>
|
||||
2. decrypt with alice's secret<br>
|
||||
3. re-encrypt with inter-tenant key<br>
|
||||
4. store (24h TTL)<br>
|
||||
5. on bob's poll: decrypt<br>
|
||||
6. re-encrypt with bob's secret<br>
|
||||
7. deliver
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="node" style="border-color:var(--yl);color:var(--yl);">relay</span>
|
||||
<span class="arrow">→</span>
|
||||
<span style="font-family:var(--pixel);font-size:10px;color:var(--tx);">re-encrypted for recipient</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="node" style="border-color:var(--gn);color:var(--gn);">bob</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="email">
|
||||
<div class="section-label">EMAIL</div>
|
||||
<div class="feat">
|
||||
<div class="feat-text">
|
||||
<h3>Built-in mail client.<br>Spam dies on arrival.</h3>
|
||||
<p>A focused IMAP mail client built into hyprcrd. Not a replacement for your full email workflow — a triage tool. See what's real, nuke what's not, move on.</p>
|
||||
<ul>
|
||||
<li>Multi-account IMAP with encrypted credential storage</li>
|
||||
<li>Automatic spam scoring — sender reputation, header analysis, pattern matching</li>
|
||||
<li>One-click spam/ham training per sender — builds your personal allowlist/blocklist</li>
|
||||
<li>Bulk select all flagged spam and delete in one action</li>
|
||||
<li>Suspicious messages marked with warning badge and score breakdown</li>
|
||||
<li>HTML rendering with external images blocked by default (tracking pixel protection)</li>
|
||||
<li>Plain text fallback — always available, always safe</li>
|
||||
<li>Pagination for large mailboxes, keyboard-friendly navigation</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-inbox">
|
||||
<div class="inbox-row">
|
||||
<span class="inbox-from">@alice</span>
|
||||
<span class="inbox-subject">Re: Sprint planning — updated timeline attached</span>
|
||||
<span class="inbox-badge" style="background:rgba(52,211,153,0.1);color:var(--gn);">inbox</span>
|
||||
</div>
|
||||
<div class="inbox-row">
|
||||
<span class="inbox-from">GitHub</span>
|
||||
<span class="inbox-subject">[hyprcrd] PR #142 merged: API v3 migration</span>
|
||||
<span class="inbox-badge" style="background:rgba(56,189,248,0.1);color:var(--ac);">notification</span>
|
||||
</div>
|
||||
<div class="inbox-row">
|
||||
<span class="inbox-from">@carol</span>
|
||||
<span class="inbox-subject">Relay docs draft — please review before Friday</span>
|
||||
<span class="inbox-badge" style="background:rgba(52,211,153,0.1);color:var(--gn);">inbox</span>
|
||||
</div>
|
||||
<div class="inbox-row spam">
|
||||
<span class="inbox-from">prince.ng@mail.ru</span>
|
||||
<span class="inbox-subject">URGENT: $4.5M inheritance awaiting your claim</span>
|
||||
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 94%</span>
|
||||
</div>
|
||||
<div class="inbox-row spam">
|
||||
<span class="inbox-from">deals@sz-wholesale.cn</span>
|
||||
<span class="inbox-subject">Limited offer!!! Best prices on electronics!!!</span>
|
||||
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 87%</span>
|
||||
</div>
|
||||
<div class="inbox-row">
|
||||
<span class="inbox-from">Sentry</span>
|
||||
<span class="inbox-subject">New issue: TypeError in /api/relay/push</span>
|
||||
<span class="inbox-badge" style="background:rgba(251,191,36,0.1);color:var(--yl);">? suspicious</span>
|
||||
</div>
|
||||
<div class="inbox-row spam">
|
||||
<span class="inbox-from">crypto-gains@proton.me</span>
|
||||
<span class="inbox-subject">Make 500% returns with this one trick</span>
|
||||
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 98%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="keys">
|
||||
<div class="section-label">KEYBOARD</div>
|
||||
<div class="feat reverse">
|
||||
<div class="feat-text">
|
||||
<h3>40+ shortcuts.<br>Zero mouse required.</h3>
|
||||
<p>Every action has a key binding. Arrow keys navigate the board. Number keys switch buckets. Quick capture creates cards without leaving the keyboard.</p>
|
||||
<ul>
|
||||
<li>Press <kbd>?</kbd> for the full shortcut reference</li>
|
||||
<li>Shortcuts auto-suppress in input fields</li>
|
||||
<li>Context-aware: editor shortcuts only fire in the editor</li>
|
||||
<li>Styled tooltips and inline help across the UI</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-keys">
|
||||
<span class="vkey">?</span>
|
||||
<span class="vkey">N</span>
|
||||
<span class="vkey">J</span>
|
||||
<span class="vkey">T</span>
|
||||
<span class="vkey">C</span>
|
||||
<span class="vkey">W</span>
|
||||
<span class="vkey">M</span>
|
||||
<span class="vkey">/</span>
|
||||
<span class="vkey">Esc</span>
|
||||
<span class="vkey">F1</span>
|
||||
<span class="vkey">Ctrl K</span>
|
||||
<span class="vkey">Ctrl N</span>
|
||||
<span class="vkey">Ctrl H</span>
|
||||
<span class="vkey">Ctrl S</span>
|
||||
<span class="vkey">Ctrl Z</span>
|
||||
<span class="vkey">↑</span>
|
||||
<span class="vkey">↓</span>
|
||||
<span class="vkey">←</span>
|
||||
<span class="vkey">→</span>
|
||||
<span class="vkey">Enter</span>
|
||||
<span class="vkey">Tab</span>
|
||||
<span class="vkey">1</span>
|
||||
<span class="vkey">2</span>
|
||||
<span class="vkey">3</span>
|
||||
<span class="vkey">...</span>
|
||||
<span class="vkey">9</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="deploy">
|
||||
<div class="section-label">DEPLOY</div>
|
||||
<div class="feat">
|
||||
<div class="feat-text">
|
||||
<h3>Docker. PHP host. Desktop app.</h3>
|
||||
<p>Three deployment paths, same app. The Electron build bundles a static PHP binary — no PHP installation needed. Data lives outside the app bundle and survives updates.</p>
|
||||
<ul>
|
||||
<li>One SQLite file — back up by copying <code>data/hyprcrd.db</code></li>
|
||||
<li>Export as JSON+media ZIP or Obsidian/Logseq markdown vault</li>
|
||||
<li>Plugin system: drop a folder, reload, feature appears</li>
|
||||
<li>Bundled: chat, email, Gitea, canvas, slideshows, Logseq import, watch</li>
|
||||
<li>Fully themed: design tokens, presets, build your own</li>
|
||||
<li>MIT licensed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feat-visual">
|
||||
<div class="vis-cards">
|
||||
<div class="vcard" style="border-color:var(--ac);">
|
||||
<div class="vcard-t">Docker</div>
|
||||
<div style="font-size:11px;color:var(--gn);line-height:1.8;">docker compose up -d<br><span style="opacity:0.3">→ localhost:8088</span></div>
|
||||
</div>
|
||||
<div class="vcard" style="border-color:var(--ac2);">
|
||||
<div class="vcard-t">PHP host</div>
|
||||
<div style="font-size:11px;color:var(--tx);line-height:1.8;">Upload to webspace<br>Set docroot → public/</div>
|
||||
</div>
|
||||
<div class="vcard" style="border-color:var(--gn);">
|
||||
<div class="vcard-t">Electron</div>
|
||||
<div style="font-size:11px;color:var(--gn);line-height:1.8;">npm run dist<br><span style="opacity:0.3">→ AppImage / DMG / NSIS</span></div>
|
||||
</div>
|
||||
<div class="vcard" style="border-color:var(--yl);">
|
||||
<div class="vcard-t">Data</div>
|
||||
<div style="font-size:11px;color:var(--tx);line-height:1.8;">data/hyprcrd.db<br>public/uploads/</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-section">
|
||||
<div>
|
||||
<h2>your brain, self-hosted.</h2>
|
||||
<p>Set a password. Start writing. Find everything.</p>
|
||||
<div class="cta-links">
|
||||
<a href="https://git.uesome.de/tanztee/hyprcrd" class="cta-btn primary">get the source</a>
|
||||
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/README.md" class="cta-btn outline">readme</a>
|
||||
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/relay/README.md" class="cta-btn outline">relay docs</a>
|
||||
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/electron/README.md" class="cta-btn outline">electron</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>hyprcrd · MIT license · v1.3.3</footer>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const text = 'hyprcrd';
|
||||
const el = document.getElementById('hero-typed');
|
||||
const cursor = document.getElementById('hero-cursor');
|
||||
if (!el) return;
|
||||
let i = 0;
|
||||
function type() {
|
||||
if (i < text.length) {
|
||||
el.textContent += text[i++];
|
||||
setTimeout(type, 90 + Math.random() * 80);
|
||||
} else {
|
||||
setTimeout(() => cursor.classList.add('cursor-done'), 1500);
|
||||
}
|
||||
}
|
||||
setTimeout(type, 400);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Ad hoc server for the hyprcrd website
|
||||
PORT=${1:-3000}
|
||||
cd "$(dirname "$0")"
|
||||
echo " hyprcrd website: http://localhost:$PORT"
|
||||
python3 -m http.server "$PORT"
|
||||
Reference in new issue
Block a user