ui overhaul
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
15
web/src/markdown.ts
Normal 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();
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user