feat: init
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
export interface DragPayload {
|
||||
id: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface DropZone {
|
||||
target: string;
|
||||
after: boolean;
|
||||
}
|
||||
|
||||
let dragging = $state<DragPayload | null>(null);
|
||||
let zone = $state<DropZone | null>(null);
|
||||
|
||||
export function isDragging(id: string): boolean {
|
||||
return dragging?.id === id;
|
||||
}
|
||||
|
||||
export function dragPayload(): DragPayload | null {
|
||||
return dragging;
|
||||
}
|
||||
|
||||
export function dropZone(): DropZone | null {
|
||||
return zone;
|
||||
}
|
||||
|
||||
const MOVE_THRESHOLD = 6;
|
||||
const HOLD_MS = 350;
|
||||
|
||||
interface DraggableOptions {
|
||||
payload: () => DragPayload;
|
||||
ondrop: (target: string, after: boolean) => void;
|
||||
enabled?: () => boolean;
|
||||
}
|
||||
|
||||
export function draggable(node: HTMLElement, options: DraggableOptions) {
|
||||
let current = options;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let holdTimer: number | null = null;
|
||||
let active = false;
|
||||
let armed = false;
|
||||
let pointerId: number | null = null;
|
||||
let frame: number | null = null;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
const swallowClick = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const clearHold = (): void => {
|
||||
if (holdTimer !== null) {
|
||||
window.clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
frame = null;
|
||||
node.style.transform = `translate3d(${lastX - startX}px, ${lastY - startY}px, 0)`;
|
||||
};
|
||||
|
||||
const begin = (): void => {
|
||||
if (active) return;
|
||||
active = true;
|
||||
dragging = current.payload();
|
||||
if (pointerId !== null) node.setPointerCapture(pointerId);
|
||||
node.style.touchAction = "none";
|
||||
node.style.willChange = "transform";
|
||||
node.classList.add("bcal-dragging");
|
||||
};
|
||||
|
||||
const finish = (commit: boolean): void => {
|
||||
clearHold();
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
}
|
||||
if (pointerId !== null && node.hasPointerCapture(pointerId)) {
|
||||
node.releasePointerCapture(pointerId);
|
||||
}
|
||||
const wasActive = active;
|
||||
if (wasActive) {
|
||||
node.addEventListener("click", swallowClick, { capture: true, once: true });
|
||||
node.style.transition = "transform 160ms ease-out";
|
||||
node.style.transform = "";
|
||||
node.classList.remove("bcal-dragging");
|
||||
window.setTimeout(() => {
|
||||
node.style.transition = "";
|
||||
node.style.willChange = "";
|
||||
}, 180);
|
||||
}
|
||||
node.style.touchAction = "";
|
||||
const landed = zone;
|
||||
active = false;
|
||||
armed = false;
|
||||
pointerId = null;
|
||||
dragging = null;
|
||||
zone = null;
|
||||
if (commit && wasActive && landed) {
|
||||
current.ondrop(landed.target, landed.after);
|
||||
}
|
||||
};
|
||||
|
||||
const onpointerdown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0) return;
|
||||
if (current.enabled && !current.enabled()) return;
|
||||
if ((event.target as HTMLElement).closest("[data-nodrag], a")) return;
|
||||
armed = true;
|
||||
pointerId = event.pointerId;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
lastX = startX;
|
||||
lastY = startY;
|
||||
if (event.pointerType === "touch") {
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdTimer = null;
|
||||
if (armed) begin();
|
||||
}, HOLD_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onpointermove = (event: PointerEvent): void => {
|
||||
if (!armed || event.pointerId !== pointerId) return;
|
||||
if (!active) {
|
||||
const moved =
|
||||
Math.abs(event.clientX - startX) > MOVE_THRESHOLD ||
|
||||
Math.abs(event.clientY - startY) > MOVE_THRESHOLD;
|
||||
if (!moved) return;
|
||||
if (event.pointerType === "touch") {
|
||||
armed = false;
|
||||
clearHold();
|
||||
return;
|
||||
}
|
||||
begin();
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
if (frame === null) frame = requestAnimationFrame(paint);
|
||||
|
||||
const previous = node.style.pointerEvents;
|
||||
node.style.pointerEvents = "none";
|
||||
const under = document.elementFromPoint(event.clientX, event.clientY);
|
||||
node.style.pointerEvents = previous;
|
||||
|
||||
const target = under?.closest<HTMLElement>("[data-drop]");
|
||||
if (!target) {
|
||||
zone = null;
|
||||
return;
|
||||
}
|
||||
const box = target.getBoundingClientRect();
|
||||
zone = {
|
||||
target: target.dataset.drop as string,
|
||||
after: event.clientY > box.top + box.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const onpointerup = (event: PointerEvent): void => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
finish(true);
|
||||
};
|
||||
|
||||
const onpointercancel = (event: PointerEvent): void => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
finish(false);
|
||||
};
|
||||
|
||||
node.addEventListener("pointerdown", onpointerdown);
|
||||
node.addEventListener("pointermove", onpointermove);
|
||||
node.addEventListener("pointerup", onpointerup);
|
||||
node.addEventListener("pointercancel", onpointercancel);
|
||||
|
||||
return {
|
||||
update(next: DraggableOptions) {
|
||||
current = next;
|
||||
},
|
||||
destroy() {
|
||||
clearHold();
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
node.removeEventListener("pointerdown", onpointerdown);
|
||||
node.removeEventListener("pointermove", onpointermove);
|
||||
node.removeEventListener("pointerup", onpointerup);
|
||||
node.removeEventListener("pointercancel", onpointercancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const monthDay = new Intl.DateTimeFormat("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
export function localKey(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function today(): string {
|
||||
return localKey(new Date());
|
||||
}
|
||||
|
||||
export function fromKey(key: string): Date {
|
||||
const [year, month, day] = key.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
export function shiftDays(key: string, days: number): string {
|
||||
const date = fromKey(key);
|
||||
date.setDate(date.getDate() + days);
|
||||
return localKey(date);
|
||||
}
|
||||
|
||||
export function daysFromToday(key: string): number {
|
||||
const target = fromKey(key);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return Math.round((target.getTime() - now.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
export function dueLabel(key: string | null): string {
|
||||
if (!key) return "";
|
||||
const diff = daysFromToday(key);
|
||||
if (diff === 0) return "today";
|
||||
if (diff === 1) return "tomorrow";
|
||||
if (diff === -1) return "yesterday";
|
||||
return monthDay.format(fromKey(key));
|
||||
}
|
||||
|
||||
export type DueTone = "overdue" | "soon" | "normal" | "none";
|
||||
|
||||
export function dueTone(key: string | null): DueTone {
|
||||
if (!key) return "none";
|
||||
const diff = daysFromToday(key);
|
||||
if (diff < 0) return "overdue";
|
||||
if (diff <= 1) return "soon";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
export function nextFriday(): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + ((5 - date.getDay() + 7) % 7 || 7));
|
||||
return localKey(date);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const LABEL_HUES = [200, 155, 75, 300, 25, 260];
|
||||
|
||||
export function labelHue(name: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) % 997;
|
||||
}
|
||||
return LABEL_HUES[hash % LABEL_HUES.length];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function laneKey(path: string, project: string): string {
|
||||
return `${encodeURIComponent(path)} ${encodeURIComponent(project)}`;
|
||||
}
|
||||
|
||||
export function parseLaneKey(key: string): { path: string; project: string } | null {
|
||||
const at = key.indexOf(" ");
|
||||
if (at === -1) return null;
|
||||
return {
|
||||
path: decodeURIComponent(key.slice(0, at)),
|
||||
project: decodeURIComponent(key.slice(at + 1)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Circle from "@lucide/svelte/icons/circle";
|
||||
import CircleCheck from "@lucide/svelte/icons/circle-check";
|
||||
import CircleDot from "@lucide/svelte/icons/circle-dot";
|
||||
import CircleSlash from "@lucide/svelte/icons/circle-slash";
|
||||
import type { Component } from "svelte";
|
||||
import type { Priority, Status } from "../model/types";
|
||||
|
||||
export interface StatusMeta {
|
||||
icon: Component;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const STATUS_META: Record<Status, StatusMeta> = {
|
||||
" ": { icon: Circle, color: "var(--muted-foreground)", label: "Todo" },
|
||||
"/": { icon: CircleDot, color: "var(--status-reply)", label: "In progress" },
|
||||
x: { icon: CircleCheck, color: "var(--status-done)", label: "Done" },
|
||||
"-": { icon: CircleSlash, color: "var(--status-skip)", label: "Cancelled" },
|
||||
};
|
||||
|
||||
export const PRIORITY_LABEL: Record<Priority, string> = {
|
||||
0: "Lowest",
|
||||
1: "Low",
|
||||
2: "No priority",
|
||||
3: "Medium",
|
||||
4: "High",
|
||||
5: "Highest",
|
||||
};
|
||||
|
||||
export const PRIORITY_ORDER: Priority[] = [5, 4, 3, 2, 1, 0];
|
||||
|
||||
export function isHot(priority: Priority): boolean {
|
||||
return priority === 5;
|
||||
}
|
||||
|
||||
export const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
Reference in New Issue
Block a user