ui overhaul
Some checks failed
Build and deploy Havenllo / Verify Go and frontend (push) Failing after 29s
Build and deploy Havenllo / Build and push image (push) Has been skipped
Build and deploy Havenllo / Apply and restart Havenllo (push) Has been skipped

This commit is contained in:
Hermes
2026-07-14 11:09:54 -03:00
parent 240f73b229
commit afd23ca751
19 changed files with 603 additions and 382 deletions

View File

@@ -141,11 +141,11 @@ export function App() {
await mutate((board) => removeCard(board, id), () => api.deleteCard(id), "Card deleted");
};
const moveCard = async (cardID: number, sourceListID: number, targetListID: number, dropIndex: number) => {
const moveCard = async (cardID: number, targetListID: number, dropIndex: number) => {
const current = boardRef.current;
const card = current?.lists.flatMap((list) => list.cards).find((item) => item.id === cardID);
if (!current || !card) return;
const position = movePosition(current.lists, cardID, sourceListID, targetListID, dropIndex);
const position = movePosition(current.lists, cardID, targetListID, dropIndex);
await editCard(cardID, { list_id: targetListID, position });
};
@@ -167,12 +167,11 @@ export function App() {
) : null}
{state.board ? (
<BoardCanvas
lists={state.board.lists}
lists={state.board?.lists ?? []}
onCreateList={createList}
onEditList={editList}
onDeleteList={deleteList}
onCreateCard={createCard}
onEditCard={editCard}
onMoveCard={moveCard}
onOpenCard={setSelectedCardID}
managingLists={managingLists}
@@ -182,6 +181,7 @@ export function App() {
<CardDetailModal
card={selectedCard}
listName={selectedList.name}
lists={state.board?.lists ?? []}
onClose={() => setSelectedCardID(null)}
onEdit={(patch) => editCard(selectedCard.id, patch)}
onDelete={() => deleteCard(selectedCard.id)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "preact/hooks";
import type { ListWithCards } from "../types";
import { useEffect, useRef, useState } from "preact/hooks";
import type { Card, ListWithCards } from "../types";
import { ListColumn } from "./ListColumn";
interface BoardCanvasProps {
@@ -8,16 +8,43 @@ interface BoardCanvasProps {
onEditList: (id: number, patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: (id: number) => Promise<void>;
onCreateCard: (listID: number, title: string) => Promise<void>;
onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onMoveCard: (cardID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void;
managingLists: boolean;
}
interface ActiveCardDrag {
cardID: number;
sourceListID: number;
targetListID: number | null;
targetIndex: number | null;
pointerID: number;
}
function dropTargetAtPoint(cardID: number, clientX: number, clientY: number): Pick<ActiveCardDrag, "targetListID" | "targetIndex"> | null {
const element = document.elementFromPoint(clientX, clientY);
const column = element?.closest<HTMLElement>("[data-list-id]");
const targetListID = Number(column?.dataset.listId);
if (!column || !Number.isInteger(targetListID)) return null;
const cards = Array.from(column.querySelectorAll<HTMLElement>("[data-card-id]"))
.filter((card) => Number(card.dataset.cardId) !== cardID);
const index = cards.findIndex((card) => {
const bounds = card.getBoundingClientRect();
return clientY < bounds.top + bounds.height / 2;
});
return { targetListID, targetIndex: index === -1 ? cards.length : index };
}
export function BoardCanvas(props: BoardCanvasProps) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
const [drag, setDrag] = useState<ActiveCardDrag | null>(null);
const dragRef = useRef<ActiveCardDrag | null>(null);
const cleanupDragRef = useRef<() => void>(() => undefined);
useEffect(() => () => cleanupDragRef.current(), []);
useEffect(() => {
if (!props.managingLists) {
@@ -40,6 +67,69 @@ export function BoardCanvas(props: BoardCanvasProps) {
}
};
const startCardDrag = (card: Card, event: PointerEvent) => {
if (dragRef.current) return;
const initial: ActiveCardDrag = {
cardID: card.id,
sourceListID: card.list_id,
targetListID: null,
targetIndex: null,
pointerID: event.pointerId
};
dragRef.current = initial;
setDrag(initial);
document.body.classList.add("is-card-dragging");
const updateTarget = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== initial.pointerID || !dragRef.current) return;
const target = dropTargetAtPoint(initial.cardID, moveEvent.clientX, moveEvent.clientY);
const current = dragRef.current;
if (current.targetListID === target?.targetListID && current.targetIndex === target?.targetIndex) return;
const next = { ...current, targetListID: target?.targetListID ?? null, targetIndex: target?.targetIndex ?? null };
dragRef.current = next;
setDrag(next);
};
const finish = (commit: boolean) => {
const finalDrag = dragRef.current;
cleanupDragRef.current();
cleanupDragRef.current = () => undefined;
dragRef.current = null;
setDrag(null);
document.body.classList.remove("is-card-dragging");
if (!commit || !finalDrag || finalDrag.targetListID === null || finalDrag.targetIndex === null) return;
const source = props.lists.find((list) => list.id === finalDrag.sourceListID);
const sourceIndex = source?.cards.findIndex((item) => item.id === finalDrag.cardID);
if (finalDrag.targetListID === finalDrag.sourceListID && finalDrag.targetIndex === sourceIndex) return;
void props.onMoveCard(finalDrag.cardID, finalDrag.targetListID, finalDrag.targetIndex);
};
const onPointerMove = (moveEvent: PointerEvent) => updateTarget(moveEvent);
const onPointerUp = (upEvent: PointerEvent) => {
if (upEvent.pointerId === initial.pointerID) finish(true);
};
const onPointerCancel = (cancelEvent: PointerEvent) => {
if (cancelEvent.pointerId === initial.pointerID) finish(false);
};
const onKeyDown = (keyEvent: KeyboardEvent) => {
if (keyEvent.key === "Escape") finish(false);
};
const cleanup = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerCancel);
window.removeEventListener("keydown", onKeyDown);
document.body.classList.remove("is-card-dragging");
};
cleanupDragRef.current = cleanup;
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerCancel);
window.addEventListener("keydown", onKeyDown);
updateTarget(event);
};
return (
<section class="board-area" aria-label="Haven task board">
<div class="board-intro">
@@ -53,54 +143,59 @@ export function BoardCanvas(props: BoardCanvasProps) {
</div>
{props.lists.length ? (
<div class={"board-rail " + (props.managingLists ? "is-managing" : "")}>
{props.lists.map((list) => (
<ListColumn
key={list.id}
list={list}
managingLists={props.managingLists}
onEditList={(patch) => props.onEditList(list.id, patch)}
onDeleteList={() => props.onDeleteList(list.id)}
onCreateCard={(title) => props.onCreateCard(list.id, title)}
onEditCard={props.onEditCard}
onMoveCard={props.onMoveCard}
onOpenCard={props.onOpenCard}
/>
))}
{props.managingLists ? <section class="add-list-column">
{adding ? (
<form
class="quick-add"
onSubmit={(event) => {
event.preventDefault();
void addList();
}}
>
<label class="sr-only" htmlFor="new-list-name">New list name</label>
<input
id="new-list-name"
value={name}
maxlength={120}
autoFocus
placeholder="List name"
onInput={(event) => setName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setAdding(false);
setName("");
}
}}
<div class="board-list-group">
{props.lists.map((list, index) => (
<ListColumn
key={list.id}
list={list}
accent={index % 3}
managingLists={props.managingLists}
draggedCardID={drag?.cardID ?? null}
dropIndex={drag?.targetListID === list.id ? drag.targetIndex : null}
isDropTarget={drag?.targetListID === list.id}
onEditList={(patch) => props.onEditList(list.id, patch)}
onDeleteList={() => props.onDeleteList(list.id)}
onCreateCard={(title) => props.onCreateCard(list.id, title)}
onStartCardDrag={startCardDrag}
onOpenCard={props.onOpenCard}
/>
<div class="quick-add-actions">
<button type="submit" class="button button-primary" disabled={saving}>Add list</button>
<button type="button" class="button button-quiet" disabled={saving} onClick={() => setAdding(false)}>Cancel</button>
</div>
</form>
) : (
<button type="button" class="add-list-button" onClick={() => setAdding(true)}>
<span aria-hidden="true"></span> Add list
</button>
)}
</section> : null}
))}
{props.managingLists ? <section class="add-list-column">
{adding ? (
<form
class="quick-add"
onSubmit={(event) => {
event.preventDefault();
void addList();
}}
>
<label class="sr-only" htmlFor="new-list-name">New list name</label>
<input
id="new-list-name"
value={name}
maxlength={120}
autoFocus
placeholder="List name"
onInput={(event) => setName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setAdding(false);
setName("");
}
}}
/>
<div class="quick-add-actions">
<button type="submit" class="button button-primary" disabled={saving}>Add list</button>
<button type="button" class="button button-quiet" disabled={saving} onClick={() => setAdding(false)}>Cancel</button>
</div>
</form>
) : (
<button type="button" class="add-list-button" onClick={() => setAdding(true)}>
<span aria-hidden="true"></span> Add list
</button>
)}
</section> : null}
</div>
</div>
) : (
<section class="empty-board" aria-live="polite">

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from "preact/hooks";
import type { Card } from "../types";
import { renderMarkdown } from "../markdown";
import type { Card, CardPatch, List } from "../types";
import { ConfirmDialog } from "./ConfirmDialog";
interface CardDetailModalProps {
card: Card;
listName: string;
lists: List[];
onClose: () => void;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onEdit: (patch: CardPatch) => Promise<void>;
onDelete: () => Promise<void>;
}
@@ -19,12 +21,13 @@ function formatTimestamp(value: string): string {
}).format(timestamp);
}
export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: CardDetailModalProps) {
export function CardDetailModal({ card, listName, lists, onClose, onEdit, onDelete }: CardDetailModalProps) {
const [title, setTitle] = useState(card.title);
const [description, setDescription] = useState(card.description);
const [descriptionMode, setDescriptionMode] = useState<"write" | "preview">("write");
const [savingTitle, setSavingTitle] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const [savingStatus, setSavingStatus] = useState(false);
const [savingList, setSavingList] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const dialogRef = useRef<HTMLElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
@@ -38,6 +41,7 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
useEffect(() => {
setTitle(card.title);
setDescription(card.description);
setDescriptionMode("write");
}, [card.id]);
useEffect(() => {
@@ -86,6 +90,18 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
}
};
const moveToList = async (listID: number) => {
if (listID === card.list_id) return;
setSavingList(true);
try {
await onEdit({ list_id: listID });
} catch {
// The app restores the card and shows the request error as a toast.
} finally {
setSavingList(false);
}
};
const close = () => {
const nextTitle = title.trim();
if (!discardTitleSaveRef.current && nextTitle && nextTitle !== card.title) void saveTitle();
@@ -93,17 +109,6 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
};
closeRef.current = close;
const toggleDone = async () => {
setSavingStatus(true);
try {
await onEdit({ done: !card.done });
} catch {
// The app restores the card and shows the request error as a toast.
} finally {
setSavingStatus(false);
}
};
const deleteCard = async () => {
setConfirmingDelete(false);
try {
@@ -173,18 +178,21 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
</div>
<div class="detail-properties" aria-label="Card properties">
<div class="detail-property"><span>List</span><strong>{listName}</strong></div>
<div class="detail-property detail-status">
<span>Status</span>
<button
type="button"
class={"status-pill " + (card.done ? "is-done" : "")}
disabled={savingStatus}
onClick={() => void toggleDone()}
>
<span aria-hidden="true">{card.done ? "✓" : "○"}</span> {card.done ? "Done" : "In progress"}
</button>
<strong class="status-pill">{listName}</strong>
</div>
<label class="detail-property detail-move">
<span>Move to list</span>
<select
value={String(card.list_id)}
aria-label="Move card to list"
disabled={savingList}
onChange={(event) => void moveToList(Number(event.currentTarget.value))}
>
{lists.map((list) => <option key={list.id} value={String(list.id)}>{list.name}</option>)}
</select>
</label>
</div>
<section class="detail-description" aria-labelledby="detail-description-heading">
@@ -195,18 +203,48 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
</div>
{descriptionChanged ? <span class="unsaved-chip">Unsaved changes</span> : null}
</div>
<textarea
value={description}
maxlength={10000}
rows={7}
placeholder="Add useful context, acceptance criteria, or a helpful note…"
aria-label="Card description"
disabled={savingDescription}
onInput={(event) => setDescription(event.currentTarget.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void saveDescription();
}}
/>
<div class="markdown-tabs" role="tablist" aria-label="Description editor mode">
<button
type="button"
role="tab"
aria-selected={descriptionMode === "write"}
class={descriptionMode === "write" ? "is-active" : ""}
onClick={() => setDescriptionMode("write")}
>
Write
</button>
<button
type="button"
role="tab"
aria-selected={descriptionMode === "preview"}
class={descriptionMode === "preview" ? "is-active" : ""}
onClick={() => setDescriptionMode("preview")}
>
Preview
</button>
<span>CommonMark supported</span>
</div>
{descriptionMode === "write" ? (
<textarea
value={description}
maxlength={10000}
rows={7}
placeholder="Add useful context, acceptance criteria, or a helpful note…"
aria-label="Card description in Markdown"
disabled={savingDescription}
onInput={(event) => setDescription(event.currentTarget.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void saveDescription();
}}
/>
) : (
<div
class="markdown-preview"
role="tabpanel"
aria-label="Description preview"
dangerouslySetInnerHTML={{ __html: renderMarkdown(description) }}
/>
)}
<div class="detail-save-row">
<span>Changes save independently. Press Ctrl + Enter to save.</span>
<button type="button" class="button button-primary" disabled={!descriptionChanged || savingDescription} onClick={() => void saveDescription()}>

View File

@@ -1,73 +1,53 @@
import { useRef, useState } from "preact/hooks";
import { beginCardDrag } from "../drag";
import { markdownToPlainText } from "../markdown";
import type { Card } from "../types";
interface CardItemProps {
card: Card;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
listName: string;
accent: number;
dragging: boolean;
onOpen: () => void;
onDragStart: (event: PointerEvent) => void;
}
export function CardItem({ card, onEdit, onOpen }: CardItemProps) {
const [saving, setSaving] = useState(false);
const didDrag = useRef(false);
const toggleDone = async () => {
setSaving(true);
try {
await onEdit({ done: !card.done });
} catch {
// The app rolls back the optimistic change and shows an error toast.
} finally {
setSaving(false);
}
};
const openIfClicked = () => {
if (didDrag.current) {
didDrag.current = false;
return;
}
onOpen();
};
export function CardItem({ card, listName, accent, dragging, onOpen, onDragStart }: CardItemProps) {
const preview = card.description ? markdownToPlainText(card.description) : "";
return (
<article
class={"card " + (card.done ? "card-done" : "")}
draggable
class={"card " + (dragging ? "is-dragging" : "")}
data-card-id={card.id}
data-accent={accent}
tabIndex={0}
onClick={openIfClicked}
onClick={onOpen}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openIfClicked();
onOpen();
}
}}
onDragStart={(event) => {
didDrag.current = true;
beginCardDrag(event as unknown as DragEvent, card);
}}
onDragEnd={() => window.setTimeout(() => { didDrag.current = false; }, 0)}
>
<div class="card-topline">
<button
type="button"
class={"done-button " + (card.done ? "is-done" : "")}
aria-label={card.done ? "Mark card active" : "Mark card done"}
title={card.done ? "Mark active" : "Mark done"}
disabled={saving}
onClick={(event) => {
class="card-grip"
aria-label={"Drag \"" + card.title + "\""}
title="Drag card"
onPointerDown={(event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
void toggleDone();
onDragStart(event as unknown as PointerEvent);
}}
onClick={(event) => event.stopPropagation()}
>
{card.done ? "✓" : ""}
<i></i><i></i><i></i><i></i><i></i><i></i>
</button>
<span class="card-grip" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></span>
<h3 class="card-title">{card.title}</h3>
<span class="card-status" data-accent={accent}>{listName}</span>
</div>
{card.description ? <p class="card-preview">{card.description}</p> : null}
{preview ? <p class="card-preview">{preview}</p> : null}
</article>
);
}

View File

@@ -1,58 +1,34 @@
import { useEffect, useState } from "preact/hooks";
import { cardDragType, readCardDrag } from "../drag";
import type { Card, ListWithCards } from "../types";
import { CardItem } from "./CardItem";
import { ConfirmDialog } from "./ConfirmDialog";
interface ListColumnProps {
list: ListWithCards;
accent: number;
managingLists: boolean;
draggedCardID: number | null;
dropIndex: number | null;
isDropTarget: boolean;
onEditList: (patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: () => Promise<void>;
onCreateCard: (title: string) => Promise<void>;
onEditCard: (cardID: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onStartCardDrag: (card: Card, event: PointerEvent) => void;
onOpenCard: (id: number) => void;
}
interface DropZoneProps {
index: number;
onDropCard: (cardID: number, sourceListID: number, index: number) => Promise<void>;
}
function DropZone({ index, onDropCard }: DropZoneProps) {
const [active, setActive] = useState(false);
return (
<div
class={"drop-zone " + (active ? "is-active" : "")}
aria-label="Drop card here"
onDragOver={(event) => {
if (event.dataTransfer?.types.includes(cardDragType)) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setActive(true);
}
}}
onDragLeave={() => setActive(false)}
onDrop={(event) => {
event.preventDefault();
setActive(false);
const payload = readCardDrag(event as unknown as DragEvent);
if (payload) void onDropCard(payload.cardID, payload.sourceListID, index);
}}
/>
);
}
export function ListColumn({
list,
accent,
onEditList,
onDeleteList,
onCreateCard,
onEditCard,
onMoveCard,
onStartCardDrag,
onOpenCard,
managingLists
managingLists,
draggedCardID,
dropIndex,
isDropTarget
}: ListColumnProps) {
const [editingName, setEditingName] = useState(false);
const [name, setName] = useState(list.name);
@@ -60,6 +36,8 @@ export function ListColumn({
const [cardTitle, setCardTitle] = useState("");
const [confirming, setConfirming] = useState(false);
const [saving, setSaving] = useState(false);
const cardsWithoutDragged = list.cards.filter((card) => card.id !== draggedCardID);
const insertionBeforeID = isDropTarget ? cardsWithoutDragged[dropIndex ?? cardsWithoutDragged.length]?.id : undefined;
useEffect(() => {
if (!managingLists && editingName) {
@@ -112,7 +90,11 @@ export function ListColumn({
return (
<>
<section class={"list-column " + (managingLists ? "is-managing" : "")}>
<section
class={"list-column " + (managingLists ? "is-managing " : "") + (isDropTarget ? "is-drop-target" : "")}
data-list-id={list.id}
data-accent={accent}
>
<div class="list-header">
<div class="list-heading">
{editingName ? (
@@ -149,18 +131,22 @@ export function ListColumn({
</button> : null}
</div>
<div class="cards-stack">
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} />
{list.cards.map((card: Card, index) => (
{list.cards.map((card) => (
<div class="card-slot" key={card.id}>
{insertionBeforeID === card.id ? <div class="card-insertion-marker" aria-hidden="true" /> : null}
<CardItem
card={card}
onEdit={(patch) => onEditCard(card.id, patch)}
listName={list.name}
accent={accent}
dragging={card.id === draggedCardID}
onDragStart={(event) => onStartCardDrag(card, event)}
onOpen={() => onOpenCard(card.id)}
/>
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
</div>
))}
{!list.cards.length ? <p class="list-empty">No cards yet. Drop one here or add the first task.</p> : null}
{isDropTarget && !insertionBeforeID ? <div class="card-insertion-marker" aria-hidden="true" /> : null}
{!list.cards.length && !isDropTarget ? <p class="list-empty">No cards yet. Drop one here or add the first task.</p> : null}
{!list.cards.length && isDropTarget ? <p class="list-empty is-dropping">Release to move the card here.</p> : null}
</div>
{addingCard ? (
<form
@@ -197,7 +183,7 @@ export function ListColumn({
</section>
<ConfirmDialog
open={confirming}
title={"Delete " + list.name + "”? "}
title={"Delete \"" + list.name + "\"?"}
detail="All cards in this list will be permanently removed."
confirmLabel="Delete list"
onCancel={() => setConfirming(false)}

View File

@@ -1,45 +1,15 @@
import type { Card, ListWithCards } from "./types";
export const cardDragType = "application/x-havenllo-card";
export interface CardDragPayload {
cardID: number;
sourceListID: number;
}
export function beginCardDrag(event: DragEvent, card: Card): void {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(cardDragType, JSON.stringify({ cardID: card.id, sourceListID: card.list_id }));
event.dataTransfer.setData("text/plain", String(card.id));
}
export function readCardDrag(event: DragEvent): CardDragPayload | null {
try {
const raw = event.dataTransfer?.getData(cardDragType);
if (!raw) return null;
const payload = JSON.parse(raw) as CardDragPayload;
if (!Number.isInteger(payload.cardID) || !Number.isInteger(payload.sourceListID)) return null;
return payload;
} catch {
return null;
}
}
import type { ListWithCards } from "./types";
export function movePosition(
lists: ListWithCards[],
cardID: number,
sourceListID: number,
targetListID: number,
dropIndex: number
): number {
const target = lists.find((list) => list.id === targetListID);
if (!target) return 1024;
const sourceIndex = target.cards.findIndex((card) => card.id === cardID);
const cards = target.cards.filter((card) => card.id !== cardID);
let index = dropIndex;
if (sourceListID === targetListID && sourceIndex !== -1 && sourceIndex < dropIndex) index -= 1;
index = Math.max(0, Math.min(index, cards.length));
const index = Math.max(0, Math.min(dropIndex, cards.length));
const previous = cards[index - 1];
const next = cards[index];
if (!previous && !next) return 1024;
@@ -47,4 +17,3 @@ export function movePosition(
if (!next) return previous.position + 1024;
return (previous.position + next.position) / 2;
}

15
web/src/markdown.ts Normal file
View File

@@ -0,0 +1,15 @@
import * as commonmark from "commonmark";
import DOMPurify from "dompurify";
const parser = new commonmark.Parser();
const renderer = new commonmark.HtmlRenderer({ safe: true });
export function renderMarkdown(source: string): string {
return DOMPurify.sanitize(renderer.render(parser.parse(source)));
}
export function markdownToPlainText(source: string): string {
const container = document.createElement("div");
container.innerHTML = renderMarkdown(source);
return (container.textContent || "").replace(/\s+/g, " ").trim();
}

View File

@@ -36,13 +36,13 @@ body {
var(--canvas);
}
button, input, textarea { font: inherit; }
button, input, textarea, select { font: inherit; }
button { color: inherit; }
button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; }
button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled) { touch-action: manipulation; }
button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible {
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, [tabindex="-1"]:focus-visible {
outline: 3px solid rgba(101, 218, 203, 0.7);
outline-offset: 3px;
}
@@ -179,9 +179,6 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
width: min(100%, 1530px);
min-height: 320px;
margin: 0 auto;
display: flex;
align-items: flex-start;
gap: 16px;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding: 2px 2px 28px;
@@ -190,6 +187,15 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.board-rail.is-managing { padding-top: 5px; }
.board-list-group {
width: max-content;
min-width: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 16px;
}
.list-column, .add-list-column {
width: min(318px, calc(100vw - 48px));
flex: 0 0 318px;
@@ -200,12 +206,17 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
}
.list-column {
--list-accent: var(--teal);
--list-accent-soft: var(--teal-soft);
padding: 14px;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.list-column:hover { border-color: rgba(160, 183, 220, 0.26); box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28); }
.list-column.is-managing { border-color: rgba(101, 218, 203, 0.34); box-shadow: 0 0 0 1px rgba(101, 218, 203, 0.07), var(--shadow); }
.list-column.is-drop-target { border-color: var(--list-accent); box-shadow: 0 0 0 2px var(--list-accent-soft), 0 20px 44px rgba(0, 0, 0, 0.28); }
.list-column[data-accent="1"] { --list-accent: var(--indigo); --list-accent-soft: rgba(145, 164, 255, 0.16); }
.list-column[data-accent="2"] { --list-accent: #f1c77a; --list-accent-soft: rgba(241, 199, 122, 0.15); }
.list-header, .list-heading, .card-topline, .quick-add-actions, .dialog-actions, .detail-header, .detail-title-row, .detail-section-heading, .detail-save-row, .detail-footer {
display: flex;
@@ -247,77 +258,92 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
background: rgba(140, 164, 201, 0.13);
}
.list-column .count-badge { color: var(--list-accent); background: var(--list-accent-soft); }
.list-delete { width: 34px; min-height: 34px; border-color: transparent; color: var(--faint); }
.list-delete:hover:not(:disabled) { color: var(--danger); border-color: rgba(255, 125, 144, 0.35); background: var(--danger-soft); }
.cards-stack { min-height: 48px; }
.card-slot { position: relative; }
.card-slot + .card-slot { margin-top: 8px; }
.drop-zone {
height: 8px;
margin: 1px 0;
border-radius: 7px;
transition: height 140ms ease, background 140ms ease, outline-color 140ms ease;
.card-insertion-marker {
height: 5px;
margin: 5px 3px;
border-radius: 999px;
background: var(--list-accent);
box-shadow: 0 0 0 4px var(--list-accent-soft), 0 0 14px var(--list-accent);
animation: insertion-pulse 760ms ease-in-out infinite alternate;
}
.drop-zone.is-active { height: 28px; background: rgba(101, 218, 203, 0.16); outline: 1px dashed var(--teal); }
.card {
padding: 12px;
border: 1px solid rgba(169, 192, 225, 0.15);
border-radius: 13px;
color: var(--ink);
cursor: grab;
cursor: pointer;
background: linear-gradient(145deg, rgba(31, 48, 76, 0.96), rgba(18, 30, 49, 0.97));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, opacity 150ms ease;
}
.card:hover { border-color: rgba(128, 205, 209, 0.43); background: linear-gradient(145deg, rgba(35, 54, 84, 0.98), rgba(20, 34, 55, 0.98)); box-shadow: var(--shadow-lifted); transform: translateY(-2px); }
.card:active { cursor: grabbing; transform: scale(0.987); }
.card:focus-visible { outline: 3px solid rgba(101, 218, 203, 0.7); outline-offset: 3px; }
.card-done { opacity: 0.72; }
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
.card.is-dragging { opacity: 0.42; transform: scale(0.985); }
body.is-card-dragging, body.is-card-dragging * { cursor: grabbing !important; user-select: none; }
.card-topline { align-items: flex-start; gap: 8px; }
.done-button {
.card-grip {
flex: 0 0 auto;
width: 24px;
min-height: 24px;
margin-top: 1px;
padding: 0;
border: 1.5px solid var(--faint);
border-radius: 8px;
color: #07131e;
cursor: pointer;
font-size: 0.82rem;
font-weight: 900;
background: transparent;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
}
.done-button:hover:not(:disabled) { border-color: var(--teal); transform: scale(1.08); }
.done-button.is-done { border-color: var(--teal); background: var(--teal); }
.card-grip {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(2, 2px);
place-content: center;
gap: 2px;
margin-top: 7px;
opacity: 0.42;
transition: opacity 140ms ease;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
cursor: grab;
background: transparent;
opacity: 0.48;
touch-action: none;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease, opacity 140ms ease;
}
.card:hover .card-grip { opacity: 0.9; }
.card-grip:hover, .card-grip:focus-visible { border-color: var(--teal); background: var(--teal-soft); opacity: 1; transform: scale(1.06); }
.card:hover .card-grip { opacity: 0.92; }
.card-grip i { width: 2px; height: 2px; border-radius: 50%; background: var(--muted); }
.card-title { flex: 1; min-width: 0; margin: 0; font-size: 0.92rem; font-weight: 720; letter-spacing: -0.012em; line-height: 1.4; }
.card-status, .status-pill {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 112px;
min-height: 28px;
padding: 5px 9px;
overflow: hidden;
border: 1px solid rgba(145, 164, 255, 0.32);
border-radius: 999px;
color: #cdd5ff;
font-size: 0.72rem;
font-weight: 740;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(145, 164, 255, 0.1);
}
.card-status { max-width: 92px; color: var(--list-accent, var(--indigo)); border-color: color-mix(in srgb, var(--list-accent, var(--indigo)) 48%, transparent); background: var(--list-accent-soft, rgba(145, 164, 255, 0.1)); }
.card-preview {
display: -webkit-box;
margin: 9px 1px 0 34px;
margin: 9px 1px 0 32px;
overflow: hidden;
color: var(--muted);
font-size: 0.78rem;
@@ -328,7 +354,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.77rem; font-weight: 680; }
input, textarea {
input, textarea, select {
width: 100%;
padding: 10px 11px;
border: 1px solid rgba(151, 174, 211, 0.28);
@@ -339,8 +365,8 @@ input, textarea {
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
}
input:hover, textarea:hover { border-color: rgba(151, 174, 211, 0.45); }
input:focus, textarea:focus { border-color: var(--teal); background: rgba(7, 15, 29, 0.88); }
input:hover, textarea:hover, select:hover { border-color: rgba(151, 174, 211, 0.45); }
input:focus, textarea:focus, select:focus { border-color: var(--teal); background: rgba(7, 15, 29, 0.88); }
textarea { min-height: 96px; resize: vertical; }
.button {
@@ -482,22 +508,9 @@ textarea { min-height: 96px; resize: vertical; }
.detail-property strong { overflow: hidden; color: var(--ink); font-size: 0.87rem; text-overflow: ellipsis; white-space: nowrap; }
.detail-status { display: flex; align-content: initial; align-items: center; justify-content: space-between; gap: 8px; }
.status-pill {
min-height: 32px;
padding: 5px 9px;
border: 1px solid rgba(145, 164, 255, 0.32);
border-radius: 999px;
color: #cdd5ff;
cursor: pointer;
font-size: 0.75rem;
font-weight: 740;
background: rgba(145, 164, 255, 0.1);
transition: transform 140ms ease, border-color 140ms ease, color 140ms ease, background 140ms ease;
}
.status-pill:hover:not(:disabled) { transform: translateY(-1px); border-color: var(--teal); color: var(--teal); }
.status-pill.is-done { border-color: rgba(101, 218, 203, 0.45); color: #07161d; background: var(--teal); }
.detail-status .status-pill { max-width: 190px; color: var(--teal); border-color: rgba(101, 218, 203, 0.42); background: var(--teal-soft); }
.detail-move { grid-column: 1 / -1; }
.detail-move select { min-height: 38px; padding-top: 7px; padding-bottom: 7px; font-size: 0.84rem; }
.detail-description { margin-left: 27px; }
.detail-section-heading { justify-content: space-between; gap: 12px; margin-bottom: 10px; }
@@ -506,6 +519,38 @@ textarea { min-height: 96px; resize: vertical; }
.unsaved-chip { padding: 4px 8px; border: 1px solid rgba(145, 164, 255, 0.34); border-radius: 999px; color: #cbd4ff; font-size: 0.69rem; font-weight: 720; background: rgba(145, 164, 255, 0.11); }
.detail-description textarea { min-height: 150px; }
.markdown-tabs { display: flex; align-items: center; gap: 4px; margin-bottom: 9px; }
.markdown-tabs button { min-height: 30px; padding: 5px 9px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); font-size: 0.74rem; font-weight: 730; cursor: pointer; background: transparent; }
.markdown-tabs button:hover, .markdown-tabs button.is-active { color: var(--ink); border-color: var(--border); background: rgba(151, 174, 211, 0.1); }
.markdown-tabs button.is-active { color: var(--teal); border-color: rgba(101, 218, 203, 0.35); background: var(--teal-soft); }
.markdown-tabs span { margin-left: auto; color: var(--faint); font-size: 0.71rem; }
.markdown-preview {
min-height: 150px;
padding: 13px;
overflow-wrap: anywhere;
border: 1px solid rgba(151, 174, 211, 0.28);
border-radius: 10px;
color: var(--ink);
line-height: 1.58;
background: rgba(6, 13, 27, 0.43);
}
.markdown-preview > :first-child { margin-top: 0; }
.markdown-preview > :last-child { margin-bottom: 0; }
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3, .markdown-preview h4 { margin: 1.1em 0 0.5em; color: var(--ink); line-height: 1.22; }
.markdown-preview h1 { font-size: 1.5rem; }
.markdown-preview h2 { font-size: 1.25rem; }
.markdown-preview h3, .markdown-preview h4 { font-size: 1.04rem; }
.markdown-preview p, .markdown-preview ul, .markdown-preview ol, .markdown-preview blockquote, .markdown-preview pre { margin: 0 0 0.9em; }
.markdown-preview ul, .markdown-preview ol { padding-left: 1.35rem; }
.markdown-preview blockquote { padding-left: 12px; border-left: 3px solid var(--teal); color: var(--muted); }
.markdown-preview code { padding: 0.13em 0.34em; border-radius: 5px; color: #c9f7ef; background: rgba(101, 218, 203, 0.12); }
.markdown-preview pre { overflow-x: auto; padding: 12px; border-radius: 9px; background: rgba(4, 10, 22, 0.7); }
.markdown-preview pre code { padding: 0; background: transparent; }
.markdown-preview a { color: var(--teal); }
.markdown-preview hr { border: 0; border-top: 1px solid var(--border); }
.detail-save-row { justify-content: space-between; gap: 16px; margin-top: 9px; color: var(--faint); font-size: 0.74rem; line-height: 1.4; }
.detail-save-row .button { flex: 0 0 auto; }
@@ -556,6 +601,7 @@ textarea { min-height: 96px; resize: vertical; }
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in { from { opacity: 0; transform: translateY(9px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes insertion-pulse { from { opacity: 0.72; } to { opacity: 1; } }
@media (max-width: 700px) {
.topbar { min-height: 66px; padding: 10px 16px; gap: 12px; }
@@ -565,6 +611,7 @@ textarea { min-height: 96px; resize: vertical; }
.board-intro { align-items: flex-start; margin-bottom: 19px; }
.board-hint { display: none; }
.board-rail { width: calc(100% + 16px); margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
.board-list-group { justify-content: flex-start; }
.list-column, .add-list-column { scroll-snap-align: start; }
.card-detail { max-height: calc(100vh - 20px); border-radius: 18px; }
.detail-backdrop, .dialog-backdrop { padding: 10px; }

View File

@@ -4,7 +4,6 @@ export interface Card {
title: string;
description: string;
position: number;
done: boolean;
created_at: string;
updated_at: string;
}
@@ -39,8 +38,6 @@ export interface ListPatch {
export interface CardPatch {
title?: string;
description?: string;
done?: boolean;
list_id?: number;
position?: number;
}