feat: init
This commit is contained in:
commit
8a609da778
59 files changed
+10053
No files matched your search
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { App, Component } from "obsidian";
|
||||
import type { BeaverCalendarSettings } from "../settings";
|
||||
import type { BoardStore } from "../vault/store.svelte";
|
||||
import { createTask } from "../vault/mutate";
|
||||
import { provideView } from "./context";
|
||||
import SphereRail from "./sphere-rail.svelte";
|
||||
import TaskCalendar from "./task-calendar.svelte";
|
||||
import TaskList from "./task-list.svelte";
|
||||
import Toolbar from "./toolbar.svelte";
|
||||
|
||||
interface Props {
|
||||
app: App;
|
||||
component: Component;
|
||||
settings: BeaverCalendarSettings;
|
||||
store: BoardStore;
|
||||
}
|
||||
|
||||
let { app, store, settings, component }: Props = $props();
|
||||
|
||||
let root = $state<HTMLElement | null>(null);
|
||||
|
||||
provideView({
|
||||
app,
|
||||
store,
|
||||
settings,
|
||||
component,
|
||||
portal: () => root as HTMLElement,
|
||||
});
|
||||
|
||||
let creating = $state(false);
|
||||
let title = $state("");
|
||||
|
||||
const scope = $derived.by(() => {
|
||||
const sphere =
|
||||
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
||||
store.spheres[0];
|
||||
if (!sphere) return null;
|
||||
const project =
|
||||
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
||||
? store.projectFilter
|
||||
: undefined) ?? sphere.projects[0];
|
||||
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
||||
});
|
||||
|
||||
async function commit() {
|
||||
const text = title.trim();
|
||||
title = "";
|
||||
creating = false;
|
||||
if (!text || !scope) return;
|
||||
await createTask(app, scope.path, scope.project, text, null);
|
||||
}
|
||||
|
||||
function focus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={root} class="bcal-root">
|
||||
<div class="bcal-app">
|
||||
{#if !store.railCollapsed}
|
||||
<SphereRail />
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<Toolbar
|
||||
oncreate={() => {
|
||||
creating = true;
|
||||
title = "";
|
||||
}}
|
||||
/>
|
||||
{#if creating}
|
||||
<div class="shrink-0 border-border border-b px-3 py-2">
|
||||
<input
|
||||
class="h-8 w-full rounded-md border border-ring bg-background px-2 text-sm outline-none"
|
||||
onblur={commit}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === "Enter") void commit();
|
||||
else if (event.key === "Escape") {
|
||||
creating = false;
|
||||
title = "";
|
||||
}
|
||||
}}
|
||||
placeholder={scope
|
||||
? `Task in ${scope.sphere} / ${scope.project}`
|
||||
: "Create a board in the boards folder first"}
|
||||
use:focus
|
||||
bind:value={title}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if store.view === "calendar"}
|
||||
<TaskCalendar />
|
||||
{:else}
|
||||
<TaskList />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
import MenuBody, { MENU_CONTENT } from "./menu-body.svelte";
|
||||
import type { MenuItem } from "./menu-body.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
items: MenuItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let { children, items, class: className, ...rest }: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
let menuOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<ContextMenu.Root bind:open={menuOpen}>
|
||||
<ContextMenu.Trigger class={className} {...rest}>
|
||||
{@render children()}
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal to={view.portal()}>
|
||||
<ContextMenu.Content class={MENU_CONTENT}>
|
||||
<MenuBody
|
||||
close={() => {
|
||||
menuOpen = false;
|
||||
}}
|
||||
{items}
|
||||
/>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import type { App, Component } from "obsidian";
|
||||
import type { BoardStore } from "../vault/store.svelte";
|
||||
import type { BeaverCalendarSettings } from "../settings";
|
||||
|
||||
export interface ViewContext {
|
||||
app: App;
|
||||
store: BoardStore;
|
||||
settings: BeaverCalendarSettings;
|
||||
component: Component;
|
||||
portal: () => HTMLElement;
|
||||
}
|
||||
|
||||
const KEY = Symbol("beaver-calendar");
|
||||
|
||||
export function provideView(context: ViewContext): void {
|
||||
setContext(KEY, context);
|
||||
}
|
||||
|
||||
export function useView(): ViewContext {
|
||||
return getContext<ViewContext>(KEY);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" module>
|
||||
export type DayTone = "none" | "inside" | "start" | "end" | "solo";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import { fromKey, localKey } from "../lib/due";
|
||||
import { WEEKDAYS } from "../lib/vocab";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface Props {
|
||||
anchor: string | null;
|
||||
onhover?: (key: string) => void;
|
||||
onpick: (key: string) => void;
|
||||
tone: (key: string) => DayTone;
|
||||
}
|
||||
|
||||
let { anchor, tone, onpick, onhover }: Props = $props();
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const monthName = new Intl.DateTimeFormat("en-US", { month: "long" });
|
||||
const todayKey = localKey(new Date());
|
||||
|
||||
let picked = $state<Date | null>(null);
|
||||
|
||||
const month = $derived.by(() => {
|
||||
if (picked) return picked;
|
||||
const base = anchor ? fromKey(anchor) : new Date();
|
||||
return new Date(base.getFullYear(), base.getMonth(), 1);
|
||||
});
|
||||
|
||||
const days = $derived.by(() => {
|
||||
const offset = (month.getDay() + 6) % 7;
|
||||
const start = new Date(month.getTime() - offset * DAY);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(start.getTime() + index * DAY);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
date,
|
||||
inMonth: date.getMonth() === month.getMonth(),
|
||||
key: localKey(date),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
picked = new Date(month.getFullYear(), month.getMonth() + delta, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-60 select-none">
|
||||
<div class="flex items-center gap-1 px-1 pb-1">
|
||||
<button
|
||||
aria-label="Previous month"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => shiftMonth(-1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
<span class="flex-1 text-center font-medium text-xs first-letter:uppercase">
|
||||
{monthName.format(month)}
|
||||
{month.getFullYear()}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Next month"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => shiftMonth(1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 pb-0.5">
|
||||
{#each WEEKDAYS as weekday (weekday)}
|
||||
<span class="py-0.5 text-center text-[10px] text-muted-foreground">
|
||||
{weekday}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-y-px">
|
||||
{#each days as day (day.key)}
|
||||
{@const shape = tone(day.key)}
|
||||
<button
|
||||
aria-label={day.date.toLocaleDateString("en-US")}
|
||||
aria-pressed={shape !== "none"}
|
||||
class={cn(
|
||||
"h-8 text-xs outline-none transition-colors tabular focus-visible:ring-2 focus-visible:ring-ring",
|
||||
day.inMonth ? "text-foreground" : "text-muted-foreground/50",
|
||||
shape === "none" && "rounded-md hover:bg-muted",
|
||||
shape === "inside" && "bg-secondary",
|
||||
shape !== "none" &&
|
||||
shape !== "inside" &&
|
||||
"bg-primary font-medium text-primary-foreground",
|
||||
shape === "solo" && "rounded-md",
|
||||
shape === "start" && "rounded-l-md",
|
||||
shape === "end" && "rounded-r-md",
|
||||
day.key === todayKey && shape === "none" && "font-medium text-signal",
|
||||
)}
|
||||
onclick={() => onpick(day.key)}
|
||||
onmouseenter={() => onhover?.(day.key)}
|
||||
type="button"
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { anchorDate } from "../model/types";
|
||||
import type { Task } from "../model/types";
|
||||
import { shiftDays, today } from "../lib/due";
|
||||
import { laneKey, parseLaneKey } from "../lib/keys";
|
||||
import { moveTask, reorderLane, setDate } from "../vault/mutate";
|
||||
import type { ViewContext } from "./context";
|
||||
|
||||
export const dropTarget = {
|
||||
task: (id: string) => `task:${id}`,
|
||||
day: (key: string) => `day:${key}`,
|
||||
lane: (path: string, project: string) => `lane:${laneKey(path, project)}`,
|
||||
sphere: (path: string) => `sphere:${encodeURIComponent(path)}`,
|
||||
undated: "undated",
|
||||
};
|
||||
|
||||
function dateField(task: Task): "due" | "scheduled" {
|
||||
return !task.due && task.scheduled ? "scheduled" : "due";
|
||||
}
|
||||
|
||||
export async function handleDrop(
|
||||
view: ViewContext,
|
||||
dragged: Task,
|
||||
target: string,
|
||||
after: boolean,
|
||||
): Promise<void> {
|
||||
const { app, store } = view;
|
||||
|
||||
if (target === dropTarget.undated) {
|
||||
await setDate(app, dragged, dateField(dragged), null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.startsWith("group:")) {
|
||||
await handleGroupDrop(view, dragged, target.slice(6));
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.startsWith("day:")) {
|
||||
const key = target.slice(4);
|
||||
if (anchorDate(dragged) === key) return;
|
||||
await setDate(app, dragged, dateField(dragged), key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.startsWith("sphere:")) {
|
||||
const path = decodeURIComponent(target.slice(7));
|
||||
if (path === dragged.path) return;
|
||||
const sphere = store.spheres.find((item) => item.path === path);
|
||||
const project = sphere?.projects[0];
|
||||
if (!project) return;
|
||||
await moveTask(app, dragged, { path, project });
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.startsWith("lane:")) {
|
||||
const lane = parseLaneKey(target.slice(5));
|
||||
if (!lane) return;
|
||||
if (lane.path === dragged.path && lane.project === dragged.project) return;
|
||||
await moveTask(app, dragged, lane);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.startsWith("task:")) {
|
||||
const id = target.slice(5);
|
||||
if (id === dragged.id) return;
|
||||
const onto = store.all.find((task) => task.id === id);
|
||||
if (!onto) return;
|
||||
|
||||
if (onto.path === dragged.path && onto.project === dragged.project) {
|
||||
const board = store.boardFor(onto.path);
|
||||
if (!board) return;
|
||||
const lane = board.tasks.filter(
|
||||
(task) => task.project === onto.project && !task.inArchive,
|
||||
);
|
||||
const rest = lane
|
||||
.map((task) => task.id)
|
||||
.filter((each) => each !== dragged.id);
|
||||
const at = rest.indexOf(onto.id);
|
||||
if (at === -1) return;
|
||||
rest.splice(after ? at + 1 : at, 0, dragged.id);
|
||||
store.sortBy = "manual";
|
||||
await reorderLane(app, board, onto.project, rest);
|
||||
return;
|
||||
}
|
||||
|
||||
const board = store.boardFor(onto.path);
|
||||
const siblings =
|
||||
board?.tasks.filter(
|
||||
(task) => task.project === onto.project && !task.inArchive,
|
||||
) ?? [];
|
||||
const at = siblings.findIndex((task) => task.id === onto.id);
|
||||
await moveTask(app, dragged, {
|
||||
path: onto.path,
|
||||
project: onto.project,
|
||||
index: at === -1 ? undefined : at + (after ? 1 : 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGroupDrop(
|
||||
view: ViewContext,
|
||||
dragged: Task,
|
||||
groupKey: string,
|
||||
): Promise<void> {
|
||||
const { app, store } = view;
|
||||
|
||||
if (store.groupBy === "due") {
|
||||
if (groupKey === "today") {
|
||||
await setDate(app, dragged, dateField(dragged), today());
|
||||
} else if (groupKey === "tomorrow") {
|
||||
await setDate(app, dragged, dateField(dragged), shiftDays(today(), 1));
|
||||
} else if (groupKey === "none") {
|
||||
await setDate(app, dragged, dateField(dragged), null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.groupBy === "sphere") {
|
||||
await handleDrop(view, dragged, dropTarget.sphere(groupKey), false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.groupBy === "project") {
|
||||
const lane = parseLaneKey(groupKey);
|
||||
if (lane) {
|
||||
await handleDrop(view, dragged, dropTarget.lane(lane.path, lane.project), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||
import Repeat from "@lucide/svelte/icons/repeat";
|
||||
import { Popover } from "bits-ui";
|
||||
import { dueLabel, dueTone, nextFriday, shiftDays, today } from "../lib/due";
|
||||
import { cn } from "../lib/utils";
|
||||
import DayGrid from "./day-grid.svelte";
|
||||
import type { DayTone } from "./day-grid.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
field?: "due" | "scheduled";
|
||||
onclear: () => void;
|
||||
onpick: (key: string) => void;
|
||||
onrepeat?: (rule: string | null) => void;
|
||||
recurrence?: string | null;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
value,
|
||||
recurrence = null,
|
||||
onpick,
|
||||
onclear,
|
||||
onrepeat,
|
||||
field = "due",
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
let open = $state(false);
|
||||
|
||||
const tone = $derived(dueTone(value));
|
||||
const label = $derived(dueLabel(value));
|
||||
|
||||
const RULES = [
|
||||
{ label: "None", value: "" },
|
||||
{ label: "Daily", value: "every day" },
|
||||
{ label: "Weekly", value: "every week" },
|
||||
{ label: "Monthly", value: "every month" },
|
||||
];
|
||||
|
||||
const QUICK = $derived([
|
||||
{ label: "Today", key: today() },
|
||||
{ label: "Tomorrow", key: shiftDays(today(), 1) },
|
||||
{ label: "Friday", key: nextFriday() },
|
||||
{ label: "In a week", key: shiftDays(today(), 7) },
|
||||
]);
|
||||
|
||||
function dayTone(key: string): DayTone {
|
||||
return key === value ? "solo" : "none";
|
||||
}
|
||||
|
||||
function pick(key: string) {
|
||||
onpick(key);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
aria-label={field === "due" ? "Due" : "Scheduled"}
|
||||
class={cn(
|
||||
"inline-flex h-6 items-center gap-1 rounded-md px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
|
||||
tone === "overdue" && "text-destructive",
|
||||
tone === "soon" && "text-status-snooze",
|
||||
tone === "none" && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
data-nodrag
|
||||
>
|
||||
{#if recurrence}
|
||||
<Repeat aria-hidden="true" class="size-3" />
|
||||
{:else}
|
||||
<CalendarDays aria-hidden="true" class="size-3" />
|
||||
{/if}
|
||||
{#if label}
|
||||
<span class="tabular">{label}</span>
|
||||
{/if}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal to={view.portal()}>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="z-50 w-fit rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-1 pb-2">
|
||||
{#each QUICK as quick (quick.label)}
|
||||
<button
|
||||
class="h-7 rounded-md border border-border px-2 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => pick(quick.key)}
|
||||
type="button"
|
||||
>
|
||||
{quick.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<DayGrid anchor={value} onpick={pick} tone={dayTone} />
|
||||
|
||||
{#if onrepeat}
|
||||
{@const known = RULES.some((rule) => rule.value === (recurrence ?? ""))}
|
||||
<div class="mt-2 border-border border-t pt-2">
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
{#each RULES as rule (rule.value)}
|
||||
<button
|
||||
aria-pressed={(recurrence ?? "") === rule.value}
|
||||
class="h-7 rounded-md border border-border px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-secondary aria-pressed:text-foreground"
|
||||
onclick={() => onrepeat?.(rule.value || null)}
|
||||
type="button"
|
||||
>
|
||||
{rule.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if recurrence && !known}
|
||||
<p class="pt-1.5 text-center text-muted-foreground text-[11px]">
|
||||
Custom rule: {recurrence}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if value}
|
||||
<button
|
||||
class="mt-2 h-7 w-full rounded-md text-muted-foreground text-xs outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => {
|
||||
onclear();
|
||||
open = false;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Clear date
|
||||
</button>
|
||||
{/if}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { laneKey, parseLaneKey } from "../lib/keys";
|
||||
import { cn } from "../lib/utils";
|
||||
import Picker from "./picker.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onpick: (path: string, project: string) => void;
|
||||
path: string;
|
||||
project: string;
|
||||
trigger: Snippet<[{ open: boolean }]>;
|
||||
}
|
||||
|
||||
let { path, project, onpick, trigger, class: className }: Props = $props();
|
||||
|
||||
const renderTrigger = $derived(trigger);
|
||||
|
||||
const view = useView();
|
||||
|
||||
const options = $derived(
|
||||
view.store.spheres.flatMap((sphere) =>
|
||||
sphere.projects.map((name) => ({
|
||||
label: name,
|
||||
hint: sphere.name,
|
||||
value: laneKey(sphere.path, name),
|
||||
})),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Picker
|
||||
label="Move to"
|
||||
onselect={(value) => {
|
||||
const lane = parseLaneKey(value);
|
||||
if (lane) onpick(lane.path, lane.project);
|
||||
}}
|
||||
{options}
|
||||
searchable
|
||||
triggerClass={cn("max-w-full", className)}
|
||||
value={laneKey(path, project)}
|
||||
>
|
||||
{#snippet trigger(state)}
|
||||
{@render renderTrigger(state)}
|
||||
{/snippet}
|
||||
</Picker>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MarkdownRenderer } from "obsidian";
|
||||
import type { App, Component } from "obsidian";
|
||||
|
||||
export interface MarkdownOptions {
|
||||
app: App;
|
||||
component: Component;
|
||||
sourcePath: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function unwrap(node: HTMLElement): void {
|
||||
let child = node.firstElementChild;
|
||||
while (
|
||||
node.childElementCount === 1 &&
|
||||
child &&
|
||||
(child.tagName === "P" || child.tagName === "DIV")
|
||||
) {
|
||||
child.replaceWith(...child.childNodes);
|
||||
child = node.firstElementChild;
|
||||
}
|
||||
}
|
||||
|
||||
export function markdown(node: HTMLElement, options: MarkdownOptions) {
|
||||
let current = options;
|
||||
let generation = 0;
|
||||
|
||||
const render = async (): Promise<void> => {
|
||||
const mine = (generation += 1);
|
||||
const { app, component, sourcePath, text } = current;
|
||||
if (typeof MarkdownRenderer?.render !== "function") {
|
||||
node.textContent = text;
|
||||
return;
|
||||
}
|
||||
const staging = document.createElement("div");
|
||||
await MarkdownRenderer.render(app, text, staging, sourcePath, component);
|
||||
if (mine !== generation) return;
|
||||
unwrap(staging);
|
||||
node.replaceChildren(...staging.childNodes);
|
||||
};
|
||||
|
||||
void render();
|
||||
|
||||
return {
|
||||
update(next: MarkdownOptions) {
|
||||
const changed =
|
||||
next.text !== current.text || next.sourcePath !== current.sourcePath;
|
||||
current = next;
|
||||
if (changed) void render();
|
||||
},
|
||||
destroy() {
|
||||
generation += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function followLink(
|
||||
app: App,
|
||||
event: MouseEvent,
|
||||
sourcePath: string,
|
||||
): boolean {
|
||||
const anchor = (event.target as HTMLElement).closest("a");
|
||||
if (!anchor) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const href = anchor.getAttribute("href") ?? "";
|
||||
if (anchor.classList.contains("internal-link")) {
|
||||
void app.workspace.openLinkText(href, sourcePath, event.ctrlKey || event.metaKey);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" module>
|
||||
export interface MenuItem {
|
||||
confirm?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
dot?: string;
|
||||
hint?: string;
|
||||
icon?: Component;
|
||||
label: string;
|
||||
onselect: () => void;
|
||||
}
|
||||
|
||||
export const MENU_CONTENT =
|
||||
"z-50 w-56 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface Props {
|
||||
close: () => void;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
let { items, close }: Props = $props();
|
||||
|
||||
let armed = $state("");
|
||||
|
||||
function choose(item: MenuItem) {
|
||||
if (item.confirm && armed !== item.label) {
|
||||
armed = item.label;
|
||||
return;
|
||||
}
|
||||
armed = "";
|
||||
close();
|
||||
item.onselect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div role="presentation">
|
||||
{#each items as item (item.label)}
|
||||
{@const arming = armed === item.label}
|
||||
<button
|
||||
class={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40",
|
||||
item.danger && "text-destructive hover:bg-destructive/10",
|
||||
arming && "bg-destructive/10 font-medium",
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
onclick={() => choose(item)}
|
||||
type="button"
|
||||
>
|
||||
{#if item.dot}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
style="background: {item.dot}"
|
||||
></span>
|
||||
{:else if item.icon}
|
||||
<item.icon aria-hidden="true" class="size-3.5 shrink-0" />
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{arming ? item.confirm : item.label}
|
||||
</span>
|
||||
{#if item.hint && !arming}
|
||||
<span class="shrink-0 text-muted-foreground text-xs">{item.hint}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts" module>
|
||||
export interface PickerOption {
|
||||
color?: string;
|
||||
hint?: string;
|
||||
icon?: Component;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import { Popover } from "bits-ui";
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
footer?: Snippet<[{ dismiss: () => void; query: string }]>;
|
||||
label: string;
|
||||
onselect: (value: string) => void;
|
||||
open?: boolean;
|
||||
options: PickerOption[];
|
||||
searchable?: boolean;
|
||||
title?: string;
|
||||
trigger: Snippet<[{ open: boolean }]>;
|
||||
triggerClass?: string;
|
||||
value?: string | null;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
footer,
|
||||
value = null,
|
||||
onselect,
|
||||
label,
|
||||
trigger,
|
||||
triggerClass,
|
||||
searchable = false,
|
||||
title,
|
||||
width,
|
||||
open: isOpen = $bindable(false),
|
||||
}: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
|
||||
let query = $state("");
|
||||
let active = $state(0);
|
||||
|
||||
const shown = $derived(
|
||||
query.trim()
|
||||
? options.filter((option) =>
|
||||
option.label.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
)
|
||||
: options,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
query = "";
|
||||
active = 0;
|
||||
}
|
||||
});
|
||||
|
||||
function choose(option: PickerOption) {
|
||||
onselect(option.value);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||
active = Math.max(0, Math.min(shown.length - 1, active + delta));
|
||||
} else if (event.key === "Enter" && shown[active]) {
|
||||
event.preventDefault();
|
||||
choose(shown[active]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger
|
||||
aria-label={label}
|
||||
class={cn(
|
||||
"inline-flex items-center outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
triggerClass,
|
||||
)}
|
||||
data-nodrag
|
||||
{title}
|
||||
>
|
||||
{@render trigger({ open: isOpen })}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal to={view.portal()}>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class={cn(
|
||||
"z-50 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none",
|
||||
width ?? (footer ? "w-72" : "w-56"),
|
||||
)}
|
||||
sideOffset={6}
|
||||
>
|
||||
{#if searchable}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="mb-1 h-8 w-full rounded-md bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||
{onkeydown}
|
||||
placeholder={label}
|
||||
bind:value={query}
|
||||
/>
|
||||
{/if}
|
||||
{#if options.length > 0}
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each shown as option, index (option.value)}
|
||||
<button
|
||||
class={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted",
|
||||
index === active && "bg-muted",
|
||||
)}
|
||||
onclick={() => choose(option)}
|
||||
onmouseenter={() => {
|
||||
active = index;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if option.icon}
|
||||
<option.icon
|
||||
aria-hidden="true"
|
||||
class="size-4 shrink-0"
|
||||
style={option.color ? `color: ${option.color}` : undefined}
|
||||
/>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
{#if option.hint}
|
||||
<span class="shrink-0 text-muted-foreground text-xs tabular">
|
||||
{option.hint}
|
||||
</span>
|
||||
{/if}
|
||||
{#if option.value === value}
|
||||
<Check aria-hidden="true" class="size-3.5 shrink-0 text-signal" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="px-2 py-3 text-center text-muted-foreground text-xs">
|
||||
Nothing found
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if footer}
|
||||
<div class={options.length > 0 ? "mt-1 border-border border-t pt-1" : ""}>
|
||||
{@render footer({
|
||||
dismiss: () => {
|
||||
isOpen = false;
|
||||
},
|
||||
query: query.trim(),
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import ChevronsDown from "@lucide/svelte/icons/chevrons-down";
|
||||
import Flame from "@lucide/svelte/icons/flame";
|
||||
import { isHot, PRIORITY_LABEL } from "../lib/vocab";
|
||||
import type { Priority } from "../model/types";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
priority: Priority;
|
||||
}
|
||||
|
||||
let { priority, class: className }: Props = $props();
|
||||
|
||||
const bars = $derived(Math.max(0, priority - 2));
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"inline-flex items-end gap-px",
|
||||
className,
|
||||
isHot(priority) && "text-destructive",
|
||||
)}
|
||||
title={PRIORITY_LABEL[priority]}
|
||||
>
|
||||
{#if isHot(priority)}
|
||||
<Flame aria-hidden="true" class="size-3.5" />
|
||||
{:else if priority === 1}
|
||||
<ChevronDown aria-hidden="true" class="size-3.5" />
|
||||
{:else if priority === 0}
|
||||
<ChevronsDown aria-hidden="true" class="size-3.5" />
|
||||
{:else}
|
||||
{#each [3, 5, 7] as height, index (height)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class={cn(
|
||||
"w-[3px] rounded-[1px] bg-current transition-opacity",
|
||||
index < bars ? "opacity-100" : "opacity-25",
|
||||
)}
|
||||
style="height: {height}px"
|
||||
></span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span class="sr-only">{PRIORITY_LABEL[priority]}</span>
|
||||
</span>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { PRIORITY_LABEL, PRIORITY_ORDER } from "../lib/vocab";
|
||||
import type { Priority } from "../model/types";
|
||||
import { cn } from "../lib/utils";
|
||||
import Picker from "./picker.svelte";
|
||||
import PriorityBars from "./priority-bars.svelte";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onchange: (priority: Priority) => void;
|
||||
priority: Priority;
|
||||
}
|
||||
|
||||
let { priority, onchange, class: className }: Props = $props();
|
||||
|
||||
const options = PRIORITY_ORDER.map((value) => ({
|
||||
label: PRIORITY_LABEL[value],
|
||||
value: String(value),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<Picker
|
||||
label="Priority"
|
||||
onselect={(value) => onchange(Number(value) as Priority)}
|
||||
{options}
|
||||
triggerClass={cn(
|
||||
"h-6 gap-1 rounded-md px-1 text-muted-foreground transition-colors hover:bg-muted",
|
||||
className,
|
||||
)}
|
||||
value={String(priority)}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<PriorityBars {priority} />
|
||||
{/snippet}
|
||||
</Picker>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import Ellipsis from "@lucide/svelte/icons/ellipsis";
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import { cn } from "../lib/utils";
|
||||
import MenuBody, { MENU_CONTENT } from "./menu-body.svelte";
|
||||
import type { MenuItem } from "./menu-body.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
items: MenuItem[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { items, label = "Task menu", class: className }: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root bind:open>
|
||||
<DropdownMenu.Trigger
|
||||
aria-label={label}
|
||||
class={cn(
|
||||
"grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
data-nodrag
|
||||
>
|
||||
<Ellipsis aria-hidden="true" class="size-3.5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal to={view.portal()}>
|
||||
<DropdownMenu.Content align="end" class={MENU_CONTENT} sideOffset={4}>
|
||||
<MenuBody
|
||||
close={() => {
|
||||
open = false;
|
||||
}}
|
||||
{items}
|
||||
/>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { dropZone } from "../lib/dnd.svelte";
|
||||
import { laneKey } from "../lib/keys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { dropTarget } from "./drop";
|
||||
import { useView } from "./context";
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
let expanded = $state<Set<string>>(new Set());
|
||||
const zone = $derived(dropZone());
|
||||
|
||||
function toggle(path: string) {
|
||||
const next = new Set(expanded);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
expanded = next;
|
||||
}
|
||||
|
||||
function selectSphere(path: string) {
|
||||
store.sphereFilter = store.sphereFilter === path ? null : path;
|
||||
store.projectFilter = null;
|
||||
}
|
||||
|
||||
function selectProject(path: string, project: string) {
|
||||
store.sphereFilter = path;
|
||||
store.projectFilter = store.projectFilter === project ? null : project;
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="flex w-52 shrink-0 flex-col overflow-y-auto border-border border-r bg-sidebar/50 py-2"
|
||||
>
|
||||
<button
|
||||
aria-current={store.sphereFilter === null}
|
||||
class="mx-2 flex h-7 items-center rounded-md px-2 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary"
|
||||
onclick={() => {
|
||||
store.sphereFilter = null;
|
||||
store.projectFilter = null;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="flex-1 truncate">All spheres</span>
|
||||
<span class="text-muted-foreground tabular">{store.all.length}</span>
|
||||
</button>
|
||||
|
||||
{#each store.spheres as sphere (sphere.path)}
|
||||
{@const open = expanded.has(sphere.path)}
|
||||
{@const target = dropTarget.sphere(sphere.path)}
|
||||
<div class="mt-1 px-2">
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-7 items-center gap-0.5 rounded-md transition-colors",
|
||||
zone?.target === target && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||
)}
|
||||
data-drop={target}
|
||||
>
|
||||
<button
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => toggle(sphere.path)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
class={cn("size-3 transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
aria-current={store.sphereFilter === sphere.path &&
|
||||
store.projectFilter === null}
|
||||
class="flex h-7 min-w-0 flex-1 items-center rounded-md px-1 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary"
|
||||
onclick={() => selectSphere(sphere.path)}
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{sphere.name}</span>
|
||||
<span class="shrink-0 text-muted-foreground tabular">
|
||||
{store.inScopeCount.byPath.get(sphere.path) ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="flex flex-col pt-0.5 pl-5">
|
||||
{#each sphere.projects as project (project)}
|
||||
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
||||
<button
|
||||
aria-current={store.sphereFilter === sphere.path &&
|
||||
store.projectFilter === project}
|
||||
class={cn(
|
||||
"flex h-6 items-center gap-1.5 rounded-md px-1 text-left text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary",
|
||||
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||
)}
|
||||
data-drop={laneTarget}
|
||||
onclick={() => selectProject(sphere.path, project)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full"
|
||||
style="background: oklch(0.6 0.12 {labelHue(project)})"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1 truncate">{project}</span>
|
||||
<span class="shrink-0 text-muted-foreground tabular">
|
||||
{store.inScopeCount.byProject.get(laneKey(sphere.path, project)) ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { STATUS_META } from "../lib/vocab";
|
||||
import { STATUS_ORDER } from "../model/types";
|
||||
import type { Status } from "../model/types";
|
||||
import { cn } from "../lib/utils";
|
||||
import Picker from "./picker.svelte";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onchange: (status: Status) => void;
|
||||
showLabel?: boolean;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
let { status, onchange, showLabel = false, class: className }: Props =
|
||||
$props();
|
||||
|
||||
const options = STATUS_ORDER.map((symbol) => ({
|
||||
color: STATUS_META[symbol].color,
|
||||
icon: STATUS_META[symbol].icon,
|
||||
label: STATUS_META[symbol].label,
|
||||
value: symbol,
|
||||
}));
|
||||
|
||||
const meta = $derived(STATUS_META[status]);
|
||||
</script>
|
||||
|
||||
<Picker
|
||||
label="Status"
|
||||
onselect={(value) => onchange(value as Status)}
|
||||
{options}
|
||||
triggerClass={cn(
|
||||
"h-6 gap-1.5 rounded-md px-1 transition-colors hover:bg-muted",
|
||||
className,
|
||||
)}
|
||||
value={status}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<meta.icon
|
||||
aria-hidden="true"
|
||||
class="size-4 shrink-0"
|
||||
style="color: {meta.color}"
|
||||
/>
|
||||
{#if showLabel}
|
||||
<span class="truncate text-sm">{meta.label}</span>
|
||||
{/if}
|
||||
<span class="sr-only">Status: {meta.label}</span>
|
||||
{/snippet}
|
||||
</Picker>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import { anchorDate, isOpen } from "../model/types";
|
||||
import type { Task } from "../model/types";
|
||||
import type { DaySlot } from "../vault/store.svelte";
|
||||
import { draggable, dropZone } from "../lib/dnd.svelte";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { cn } from "../lib/utils";
|
||||
import { STATUS_META, WEEKDAYS } from "../lib/vocab";
|
||||
import { createTask, setStatus } from "../vault/mutate";
|
||||
import ContextArea from "./context-area.svelte";
|
||||
import RowMenu from "./row-menu.svelte";
|
||||
import { dropTarget, handleDrop } from "./drop";
|
||||
import { followLink, markdown } from "./markdown";
|
||||
import { taskMenu } from "./task-menu";
|
||||
import { useView } from "./context";
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
let addingDay = $state<string | null>(null);
|
||||
let dayTitle = $state("");
|
||||
|
||||
const zone = $derived(dropZone());
|
||||
|
||||
const scope = $derived.by(() => {
|
||||
const sphere =
|
||||
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
||||
store.spheres[0];
|
||||
if (!sphere) return null;
|
||||
const project =
|
||||
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
||||
? store.projectFilter
|
||||
: undefined) ?? sphere.projects[0];
|
||||
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
||||
});
|
||||
|
||||
async function addOnDay(key: string) {
|
||||
const title = dayTitle.trim();
|
||||
dayTitle = "";
|
||||
addingDay = null;
|
||||
if (!title || !scope) return;
|
||||
await createTask(view.app, scope.path, scope.project, title, key);
|
||||
}
|
||||
|
||||
function focus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet card(task: Task)}
|
||||
{@const meta = STATUS_META[task.status]}
|
||||
<ContextArea class="contents" items={taskMenu(view, task)}>
|
||||
<div
|
||||
class={cn(
|
||||
"group/card flex min-w-0 cursor-grab select-none flex-col gap-1 rounded-md border border-border bg-card p-1.5 transition-colors hover:border-border/90 hover:bg-secondary/40",
|
||||
!anchorDate(task) && "border-dashed hover:border-solid",
|
||||
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
||||
)}
|
||||
use:draggable={{
|
||||
payload: () => ({ id: task.id, kind: "task" }),
|
||||
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-1">
|
||||
<button
|
||||
aria-label={`Status: ${meta.label}`}
|
||||
class="grid size-4 shrink-0 place-items-center rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-nodrag
|
||||
onclick={() =>
|
||||
void setStatus(view.app, task, isOpen(task.status) ? "x" : " ")}
|
||||
type="button"
|
||||
>
|
||||
<meta.icon aria-hidden="true" class="size-3.5" style="color: {meta.color}" />
|
||||
</button>
|
||||
<div
|
||||
class={cn(
|
||||
"bcal-md line-clamp-2 min-w-0 flex-1 font-medium text-xs leading-snug",
|
||||
!isOpen(task.status) && "line-through decoration-muted-foreground/50",
|
||||
)}
|
||||
onclick={(event) => followLink(view.app, event, task.path)}
|
||||
onkeydown={() => {}}
|
||||
role="presentation"
|
||||
title={task.text}
|
||||
use:markdown={{
|
||||
app: view.app,
|
||||
component: view.component,
|
||||
sourcePath: task.path,
|
||||
text: task.text,
|
||||
}}
|
||||
></div>
|
||||
<RowMenu
|
||||
class="size-4 opacity-0 transition-opacity group-hover/card:opacity-100 touch-shown"
|
||||
items={taskMenu(view, task)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1 pl-5">
|
||||
<span
|
||||
class="max-w-20 shrink-0 truncate rounded border border-border bg-secondary/50 px-1 text-[10px] text-muted-foreground leading-[1.4]"
|
||||
>
|
||||
{task.sphere}
|
||||
</span>
|
||||
{#if task.inArchive}
|
||||
<span
|
||||
class="inline-flex max-w-24 shrink-0 items-center gap-0.5 truncate rounded border border-border border-dashed px-1 text-[10px] text-muted-foreground leading-[1.4]"
|
||||
>
|
||||
<Archive aria-hidden="true" class="size-2.5" />
|
||||
{task.project}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="hue-chip max-w-24 truncate rounded border px-1 text-[10px] leading-[1.4]"
|
||||
style="--hue: {labelHue(task.project)}"
|
||||
>
|
||||
{task.project}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ContextArea>
|
||||
{/snippet}
|
||||
|
||||
{#snippet dayCell(day: DaySlot)}
|
||||
{@const target = dropTarget.day(day.key)}
|
||||
<div
|
||||
class={cn(
|
||||
"group/day flex min-h-[7rem] min-w-0 flex-col gap-1 border-border/40 border-r border-b p-1.5 transition-colors last:border-r-0",
|
||||
day.inMonth ? "bg-transparent" : "bg-secondary/25",
|
||||
day.isToday && "bg-primary/[0.06]",
|
||||
zone?.target === target &&
|
||||
"bg-secondary/70 inset-ring-2 inset-ring-signal/70",
|
||||
)}
|
||||
data-drop={target}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
class={cn(
|
||||
"rounded-md px-1 text-xs tabular",
|
||||
day.isToday && "bg-primary font-semibold text-primary-foreground",
|
||||
!day.inMonth && "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Add task"
|
||||
class="ml-auto grid size-5 place-items-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover/day:opacity-100 touch-shown"
|
||||
onclick={() => {
|
||||
addingDay = day.key;
|
||||
dayTitle = "";
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if addingDay === day.key}
|
||||
<input
|
||||
class="h-6 w-full shrink-0 rounded-md border border-ring bg-background px-1.5 text-xs outline-none"
|
||||
onblur={() => void addOnDay(day.key)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === "Enter") void addOnDay(day.key);
|
||||
else if (event.key === "Escape") {
|
||||
addingDay = null;
|
||||
dayTitle = "";
|
||||
}
|
||||
}}
|
||||
placeholder={scope ? `Task in ${scope.project}` : "Task"}
|
||||
use:focus
|
||||
bind:value={dayTitle}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each day.tasks as task (task.id)}
|
||||
{@render card(task)}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex shrink-0 items-center gap-1 px-3 py-2">
|
||||
<h2 class="font-semibold text-sm first-letter:uppercase">
|
||||
{store.monthLabel}
|
||||
</h2>
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<button
|
||||
aria-label="Previous month"
|
||||
class="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => store.stepMonth(-1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
class="h-7 rounded-4xl border border-border px-2.5 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => store.goToday()}
|
||||
type="button"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
aria-label="Next month"
|
||||
class="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => store.stepMonth(1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto @min-[46rem]:hidden">
|
||||
{#each store.agendaDays as day (day.key)}
|
||||
<div class="px-3">
|
||||
<h3
|
||||
class="sticky top-0 z-10 flex items-center gap-2 bg-background/95 py-1.5 font-medium text-muted-foreground text-xs backdrop-blur"
|
||||
data-drop={dropTarget.day(day.key)}
|
||||
>
|
||||
<span class={cn("tabular", day.isToday && "text-signal")}>
|
||||
{day.date.toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
weekday: "short",
|
||||
})}
|
||||
</span>
|
||||
<span class="ml-auto tabular">{day.tasks.length}</span>
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1.5 pb-2">
|
||||
{#each day.tasks as task (task.id)}
|
||||
{@render card(task)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-3 py-6 text-center text-muted-foreground text-xs">
|
||||
Nothing scheduled this month
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="hidden min-h-0 flex-1 flex-col px-3 pb-3 @min-[46rem]:flex">
|
||||
<div
|
||||
class="grid shrink-0 grid-cols-7 border-border border-b pb-1.5 text-muted-foreground text-xs"
|
||||
>
|
||||
{#each WEEKDAYS as weekday (weekday)}
|
||||
<span class="text-center">{weekday}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto rounded-b-lg border-border border-x border-b"
|
||||
>
|
||||
{#each store.monthWeeks as week, index (index)}
|
||||
<div class="grid grid-cols-7">
|
||||
{#each week as day (day.key)}
|
||||
{@render dayCell(day)}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if store.undated.length > 0}
|
||||
<div
|
||||
class={cn(
|
||||
"shrink-0 border-border border-t bg-sidebar/40 transition-colors",
|
||||
zone?.target === dropTarget.undated && "bg-secondary/70",
|
||||
)}
|
||||
data-drop={dropTarget.undated}
|
||||
>
|
||||
<button
|
||||
aria-expanded={!store.undatedCollapsed}
|
||||
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left font-medium text-muted-foreground text-xs outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => {
|
||||
store.undatedCollapsed = !store.undatedCollapsed;
|
||||
store.savePrefs();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
class={cn(
|
||||
"size-3 transition-transform",
|
||||
!store.undatedCollapsed && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span>No date</span>
|
||||
<span class="tabular">{store.undated.length}</span>
|
||||
</button>
|
||||
{#if !store.undatedCollapsed}
|
||||
<div
|
||||
class="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto px-3 pt-0.5 pb-2"
|
||||
>
|
||||
{#each store.undated as task (task.id)}
|
||||
<div class="w-48">{@render card(task)}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { dropZone } from "../lib/dnd.svelte";
|
||||
import { cn } from "../lib/utils";
|
||||
import TaskRow from "./task-row.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
const zone = $derived(dropZone());
|
||||
|
||||
function groupTarget(key: string): string {
|
||||
return `group:${key}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each store.groups as group (group.key)}
|
||||
<section>
|
||||
<h2
|
||||
class={cn(
|
||||
"sticky top-0 z-10 flex items-center gap-2 border-border/60 border-b bg-background/95 px-3 py-1.5 font-medium text-muted-foreground text-xs backdrop-blur transition-colors",
|
||||
zone?.target === groupTarget(group.key) && "bg-secondary/70",
|
||||
)}
|
||||
data-drop={groupTarget(group.key)}
|
||||
>
|
||||
<span class="truncate">{group.label}</span>
|
||||
{#if group.hint}
|
||||
<span class="truncate text-muted-foreground/60">{group.hint}</span>
|
||||
{/if}
|
||||
<span class="ml-auto tabular">{group.tasks.length}</span>
|
||||
</h2>
|
||||
{#each group.tasks as task (task.id)}
|
||||
<TaskRow
|
||||
showProject={store.groupBy !== "project"}
|
||||
showSphere={store.groupBy !== "sphere"}
|
||||
{task}
|
||||
/>
|
||||
{/each}
|
||||
</section>
|
||||
{:else}
|
||||
<p class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{store.search.trim() ? "Nothing matches" : "No tasks"}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Notice, TFile } from "obsidian";
|
||||
import ExternalLink from "@lucide/svelte/icons/external-link";
|
||||
import FileText from "@lucide/svelte/icons/file-text";
|
||||
import PenLine from "@lucide/svelte/icons/pen-line";
|
||||
import { STATUS_META } from "../lib/vocab";
|
||||
import { STATUS_ORDER } from "../model/types";
|
||||
import type { Task } from "../model/types";
|
||||
import { replaceRaw, setStatus } from "../vault/mutate";
|
||||
import { tasksApi, warnMissingTasks } from "../tasks-api";
|
||||
import type { MenuItem } from "./menu-body.svelte";
|
||||
import type { ViewContext } from "./context";
|
||||
|
||||
async function revealInFile(view: ViewContext, task: Task): Promise<void> {
|
||||
const file = view.app.vault.getAbstractFileByPath(task.path);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const leaf = view.app.workspace.getLeaf("tab");
|
||||
await leaf.openFile(file);
|
||||
const editor = view.app.workspace.activeEditor?.editor;
|
||||
if (editor) {
|
||||
editor.setCursor({ line: task.line, ch: 0 });
|
||||
editor.scrollIntoView(
|
||||
{ from: { line: task.line, ch: 0 }, to: { line: task.line, ch: 0 } },
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function editInTasks(view: ViewContext, task: Task): Promise<void> {
|
||||
const api = tasksApi(view.app);
|
||||
if (!api) {
|
||||
warnMissingTasks();
|
||||
return;
|
||||
}
|
||||
const edited = await api.editTaskLineModal(task.raw);
|
||||
if (!edited || edited === task.raw) return;
|
||||
await replaceRaw(view.app, task, edited);
|
||||
}
|
||||
|
||||
export function taskMenu(view: ViewContext, task: Task): MenuItem[] {
|
||||
const statuses: MenuItem[] = STATUS_ORDER.filter(
|
||||
(symbol) => symbol !== task.status,
|
||||
).map((symbol) => ({
|
||||
dot: STATUS_META[symbol].color,
|
||||
label: STATUS_META[symbol].label,
|
||||
onselect: () => void setStatus(view.app, task, symbol),
|
||||
}));
|
||||
|
||||
return [
|
||||
...statuses,
|
||||
{
|
||||
icon: PenLine,
|
||||
label: "Edit in Tasks",
|
||||
onselect: () => void editInTasks(view, task),
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Reveal in file",
|
||||
hint: task.sphere,
|
||||
onselect: () => void revealInFile(view, task),
|
||||
},
|
||||
{
|
||||
icon: ExternalLink,
|
||||
label: "Copy raw line",
|
||||
onselect: () => {
|
||||
void navigator.clipboard.writeText(task.raw);
|
||||
new Notice("Beaver Calendar: line copied");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
import { anchorDate, isOpen } from "../model/types";
|
||||
import type { Task } from "../model/types";
|
||||
import { draggable, dropZone } from "../lib/dnd.svelte";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { cn } from "../lib/utils";
|
||||
import { STATUS_META } from "../lib/vocab";
|
||||
import {
|
||||
moveTask,
|
||||
setDate,
|
||||
setRecurrence,
|
||||
setStatus,
|
||||
setText,
|
||||
} from "../vault/mutate";
|
||||
import ContextArea from "./context-area.svelte";
|
||||
import DuePicker from "./due-picker.svelte";
|
||||
import LanePicker from "./lane-picker.svelte";
|
||||
import PriorityBars from "./priority-bars.svelte";
|
||||
import RowMenu from "./row-menu.svelte";
|
||||
import { dropTarget, handleDrop } from "./drop";
|
||||
import { followLink, markdown } from "./markdown";
|
||||
import { taskMenu } from "./task-menu";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
showProject?: boolean;
|
||||
showSphere?: boolean;
|
||||
task: Task;
|
||||
}
|
||||
|
||||
let { task, showSphere = true, showProject = true }: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state("");
|
||||
|
||||
const meta = $derived(STATUS_META[task.status]);
|
||||
const target = $derived(dropTarget.task(task.id));
|
||||
const zone = $derived(dropZone());
|
||||
const isDropTarget = $derived(zone?.target === target);
|
||||
const date = $derived(anchorDate(task));
|
||||
const field = $derived(!task.due && task.scheduled ? "scheduled" : "due");
|
||||
|
||||
function startEdit() {
|
||||
draft = task.text;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function commitEdit() {
|
||||
if (!editing) return;
|
||||
editing = false;
|
||||
await setText(view.app, task, draft);
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitEdit();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
editing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
await setStatus(view.app, task, isOpen(task.status) ? "x" : " ");
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextArea class="contents" items={taskMenu(view, task)}>
|
||||
<div
|
||||
class={cn(
|
||||
"group/task grid select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
|
||||
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
||||
isDropTarget &&
|
||||
(zone?.after
|
||||
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
|
||||
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
|
||||
)}
|
||||
data-drop={target}
|
||||
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
|
||||
use:draggable={{
|
||||
payload: () => ({ id: task.id, kind: "task" }),
|
||||
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
||||
enabled: () => !editing,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label={`Status: ${meta.label}`}
|
||||
class="grid size-5 place-items-center rounded-md outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-nodrag
|
||||
onclick={toggle}
|
||||
title={meta.label}
|
||||
type="button"
|
||||
>
|
||||
<meta.icon aria-hidden="true" class="size-4" style="color: {meta.color}" />
|
||||
</button>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-1.5 py-2.5">
|
||||
{#if showSphere}
|
||||
<span
|
||||
class="hidden shrink-0 rounded-md border border-border bg-secondary/40 px-1.5 py-0.5 font-medium text-[11px] text-muted-foreground @min-[30rem]:inline-block"
|
||||
>
|
||||
{task.sphere}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.inArchive}
|
||||
<span
|
||||
class="hidden shrink-0 items-center gap-1 rounded-md border border-border border-dashed px-1.5 py-0.5 text-[11px] text-muted-foreground @min-[24rem]:inline-flex"
|
||||
title={task.project}
|
||||
>
|
||||
<Archive aria-hidden="true" class="size-3" />
|
||||
{task.project}
|
||||
</span>
|
||||
{:else if showProject}
|
||||
<LanePicker
|
||||
class="hidden shrink-0 @min-[24rem]:inline-flex"
|
||||
onpick={(path, project) =>
|
||||
void moveTask(view.app, task, { path, project })}
|
||||
path={task.path}
|
||||
project={task.project}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span
|
||||
class="hue-chip max-w-32 truncate rounded-md border px-1.5 py-0.5 text-[11px]"
|
||||
style="--hue: {labelHue(task.project)}"
|
||||
>
|
||||
{task.project}
|
||||
</span>
|
||||
{/snippet}
|
||||
</LanePicker>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="min-w-0 flex-1 rounded-md border border-ring bg-background px-1.5 py-0.5 text-sm outline-none"
|
||||
onblur={commitEdit}
|
||||
{onkeydown}
|
||||
bind:value={draft}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class={cn(
|
||||
"bcal-md min-w-0 flex-1 cursor-text truncate font-medium text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
!isOpen(task.status) && "line-through decoration-muted-foreground/50",
|
||||
)}
|
||||
onclick={(event) => {
|
||||
if (!followLink(view.app, event, task.path)) startEdit();
|
||||
}}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === "Enter") startEdit();
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title={task.text}
|
||||
use:markdown={{
|
||||
app: view.app,
|
||||
component: view.component,
|
||||
sourcePath: task.path,
|
||||
text: task.text,
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#if task.priority !== 2}
|
||||
<PriorityBars class="shrink-0" priority={task.priority} />
|
||||
{/if}
|
||||
<div class="flex justify-end @min-[22rem]:w-24">
|
||||
<DuePicker
|
||||
class={cn(
|
||||
!date &&
|
||||
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
|
||||
)}
|
||||
{field}
|
||||
onclear={() => void setDate(view.app, task, field, null)}
|
||||
onpick={(key) => void setDate(view.app, task, field, key)}
|
||||
onrepeat={(rule) => void setRecurrence(view.app, task, rule)}
|
||||
recurrence={task.recurrence}
|
||||
value={date}
|
||||
/>
|
||||
</div>
|
||||
<RowMenu
|
||||
class="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/task:opacity-100 touch-shown"
|
||||
items={taskMenu(view, task)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextArea>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import ArrowDownWideNarrow from "@lucide/svelte/icons/arrow-down-wide-narrow";
|
||||
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||
import CircleCheck from "@lucide/svelte/icons/circle-check";
|
||||
import Group from "@lucide/svelte/icons/group";
|
||||
import List from "@lucide/svelte/icons/list";
|
||||
import PanelLeft from "@lucide/svelte/icons/panel-left";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { GroupBy, SortBy, ViewMode } from "../settings";
|
||||
import Picker from "./picker.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
oncreate: () => void;
|
||||
}
|
||||
|
||||
let { oncreate }: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
const VIEWS: { icon: typeof List; id: ViewMode; label: string }[] = [
|
||||
{ icon: List, id: "list", label: "List" },
|
||||
{ icon: CalendarDays, id: "calendar", label: "Calendar" },
|
||||
];
|
||||
|
||||
const GROUPS: { label: string; value: GroupBy }[] = [
|
||||
{ label: "Spheres", value: "sphere" },
|
||||
{ label: "Projects", value: "project" },
|
||||
{ label: "Status", value: "status" },
|
||||
{ label: "Due", value: "due" },
|
||||
];
|
||||
|
||||
const SORTS = $derived(
|
||||
[
|
||||
{ label: "Manual", value: "manual" as SortBy },
|
||||
{ label: "Priority", value: "priority" as SortBy },
|
||||
{ label: "Due", value: "due" as SortBy },
|
||||
].filter((option) => store.view === "list" || option.value !== "due"),
|
||||
);
|
||||
|
||||
const PILL =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 overflow-x-auto border-border border-b px-3 py-2 scrollbar-none"
|
||||
>
|
||||
<button
|
||||
aria-label="Toggle spheres"
|
||||
aria-pressed={!store.railCollapsed}
|
||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||
onclick={() => {
|
||||
store.railCollapsed = !store.railCollapsed;
|
||||
store.savePrefs();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PanelLeft aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-0.5 rounded-4xl border border-border p-0.5"
|
||||
role="tablist"
|
||||
>
|
||||
{#each VIEWS as mode (mode.id)}
|
||||
<button
|
||||
aria-selected={store.view === mode.id}
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-4xl px-2.5 font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring aria-selected:bg-primary aria-selected:text-primary-foreground"
|
||||
onclick={() => {
|
||||
store.view = mode.id;
|
||||
store.savePrefs();
|
||||
}}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<mode.icon aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[34rem]:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if store.view === "list"}
|
||||
<Picker
|
||||
label="Group by"
|
||||
onselect={(value) => {
|
||||
store.groupBy = value as GroupBy;
|
||||
store.savePrefs();
|
||||
}}
|
||||
options={GROUPS}
|
||||
triggerClass={PILL}
|
||||
value={store.groupBy}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Group aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[40rem]:inline">
|
||||
{GROUPS.find((item) => item.value === store.groupBy)?.label}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Picker>
|
||||
{/if}
|
||||
|
||||
<Picker
|
||||
label="Sort by"
|
||||
onselect={(value) => {
|
||||
store.sortBy = value as SortBy;
|
||||
}}
|
||||
options={SORTS}
|
||||
triggerClass={PILL}
|
||||
value={store.sortBy}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<ArrowDownWideNarrow aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[40rem]:inline">
|
||||
{SORTS.find((item) => item.value === store.sortBy)?.label}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Picker>
|
||||
|
||||
<button
|
||||
aria-label="Show completed"
|
||||
aria-pressed={store.showDone}
|
||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||
onclick={() => {
|
||||
store.showDone = !store.showDone;
|
||||
store.savePrefs();
|
||||
}}
|
||||
title="Show completed"
|
||||
type="button"
|
||||
>
|
||||
<CircleCheck aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<label
|
||||
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<Search aria-hidden="true" class="size-3.5 text-muted-foreground" />
|
||||
<input
|
||||
class="w-24 min-w-0 bg-transparent text-xs outline-none transition-[width] placeholder:text-muted-foreground focus:w-40"
|
||||
placeholder="Search"
|
||||
type="text"
|
||||
bind:value={store.search}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
aria-label="New task"
|
||||
class={cn(PILL, "ml-auto bg-primary text-primary-foreground hover:bg-primary/90")}
|
||||
onclick={oncreate}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[34rem]:inline">Task</span>
|
||||
</button>
|
||||
</div>
|
||||
Reference in new issue
Block a user