diff --git a/.tokeignore b/.tokeignore index ac6c384..9f1ea2a 100644 --- a/.tokeignore +++ b/.tokeignore @@ -9,5 +9,5 @@ MiSans.css *.yaml *.yml *.mdx -packages/solid/src/drizzle/cred -packages/solid/src/drizzle/main \ No newline at end of file +packages/core/drizzle/cred +packages/core/drizzle/main \ No newline at end of file diff --git a/packages/tracker/.dockerignore b/packages/tracker/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/packages/tracker/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/packages/tracker/.gitignore b/packages/tracker/.gitignore new file mode 100644 index 0000000..28fe6d6 --- /dev/null +++ b/packages/tracker/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ + +docs_llm + +data/ \ No newline at end of file diff --git a/packages/tracker/.npmrc b/packages/tracker/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/packages/tracker/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/packages/tracker/.tokeignore b/packages/tracker/.tokeignore new file mode 100644 index 0000000..d644590 --- /dev/null +++ b/packages/tracker/.tokeignore @@ -0,0 +1 @@ +app/components/ui \ No newline at end of file diff --git a/packages/tracker/Dockerfile b/packages/tracker/Dockerfile new file mode 100644 index 0000000..207bf93 --- /dev/null +++ b/packages/tracker/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/packages/tracker/app/admin/users.tsx b/packages/tracker/app/admin/users.tsx new file mode 100644 index 0000000..63d978d --- /dev/null +++ b/packages/tracker/app/admin/users.tsx @@ -0,0 +1,367 @@ +import type { Route } from "./+types/users"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { ArrowLeft, Plus, Trash2, Edit, Shield, ShieldOff, UserPlus } from "lucide-react"; +import { Link, Form } from "react-router"; +import { db } from "@lib/db"; +import { users } from "@lib/db/schema"; +import { getCurrentUser } from "@lib/auth-utils"; +import Layout from "@/components/layout"; +import { eq } from "drizzle-orm"; +import { hashPassword } from "@lib/auth"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { generate as generateId } from "@alikia/random-key"; +import { Checkbox } from "@/components/ui/checkbox"; + +export function meta({}: Route.MetaArgs) { + return [ + { title: "User Management - Admin" }, + { name: "description", content: "Manage users and permissions" } + ]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getCurrentUser(request); + if (!user || !user.isAdmin) { + throw new Response("You do not have permission to view this page", { status: 403 }); + } + + // Fetch all users + const allUsers = await db.select().from(users).orderBy(users.createdAt); + + return { users: allUsers, currentUser: user }; +} + +export async function action({ request }: Route.ActionArgs) { + const user = await getCurrentUser(request); + if (!user || !user.isAdmin) { + throw new Response("You do not have permission to view this page", { status: 403 }); + } + + const formData = await request.formData(); + const intent = formData.get("intent"); + const userId = formData.get("userId") as string; + + if (intent === "toggleAdmin") { + const targetUser = await db.select().from(users).where(eq(users.id, userId)).get(); + if (!targetUser) { + return { error: "User not found" }; + } + + // Prevent self-demotion + if (targetUser.id === user.id) { + return { error: "Cannot change your own admin status" }; + } + + await db + .update(users) + .set({ isAdmin: !targetUser.isAdmin }) + .where(eq(users.id, userId)); + + return { success: true }; + } + + if (intent === "deleteUser") { + const targetUser = await db.select().from(users).where(eq(users.id, userId)).get(); + if (!targetUser) { + return { error: "User not found" }; + } + + // Prevent self-deletion + if (targetUser.id === user.id) { + return { error: "Cannot delete your own account" }; + } + + // Prevent deleting admin users + if (targetUser.isAdmin) { + return { error: "Cannot delete admin users" }; + } + + await db.delete(users).where(eq(users.id, userId)); + + return { success: true }; + } + + if (intent === "createUser") { + const username = formData.get("username") as string; + const password = formData.get("password") as string; + const isAdmin = formData.get("isAdmin") === "on"; + + if (!username || !password) { + return { error: "Username and password are required" }; + } + + // Check if username already exists + const existingUser = await db.select().from(users).where(eq(users.username, username)).get(); + if (existingUser) { + return { error: "Username already exists" }; + } + + const hashedPassword = await hashPassword(password); + await db.insert(users).values({ + id: await generateId(6), + username, + password: hashedPassword, + isAdmin, + createdAt: new Date(), + updatedAt: new Date() + }); + + return { success: true }; + } + + if (intent === "updateUser") { + const username = formData.get("username") as string; + const password = formData.get("password") as string; + const isAdmin = formData.get("isAdmin") === "on"; + + if (!username) { + return { error: "Username is required" }; + } + + const targetUser = await db.select().from(users).where(eq(users.id, userId)).get(); + if (!targetUser) { + return { error: "User not found" }; + } + + // Check if username already exists (excluding current user) + const existingUser = await db.select().from(users).where(eq(users.username, username)).get(); + if (existingUser && existingUser.id !== userId) { + return { error: "Username already exists" }; + } + + const updateData: any = { + username, + isAdmin, + }; + + // Only update password if provided + if (password) { + updateData.password = await hashPassword(password); + } + + await db + .update(users) + .set(updateData) + .where(eq(users.id, userId)); + + return { success: true }; + } + + return { error: "Unknown action" }; +} + +export default function UserManagement({ loaderData }: Route.ComponentProps) { + const { users, currentUser } = loaderData; + + return ( + + {/* Header */} +
+
+

User Management

+

Manage users and their permissions

+
+
+ + + + + + + Add New User + + Create a new user account with username and password. + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ +
+
+ + {/* Users Table */} + + + Users + Manage user accounts and permissions + + + + + + Username + Admin + Created + Actions + + + + {users.map((user) => ( + + {user.username} + + {user.isAdmin ? ( + Admin + ) : ( + User + )} + + + {new Date(user.createdAt).toLocaleDateString()} + + +
+ + + + + + + Edit User + + Update user information and permissions. + + +
+ + +
+
+ + +
+
+ + +
+
+ + + {user.id === currentUser.id && ( + (Cannot change your own admin status) + )} +
+
+ + + + +
+
+
+ + + + +
+ + + + +
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/packages/tracker/app/app.css b/packages/tracker/app/app.css new file mode 100644 index 0000000..84b9670 --- /dev/null +++ b/packages/tracker/app/app.css @@ -0,0 +1,201 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: + "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.1448 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.1448 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.1448 0 0); + --primary: oklch(0 0 0); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.9702 0 0); + --secondary-foreground: oklch(0.2046 0 0); + --muted: oklch(0.9702 0 0); + --muted-foreground: oklch(0.5486 0 0); + --accent: oklch(0.9702 0 0); + --accent-foreground: oklch(0.2046 0 0); + --destructive: oklch(0.583 0.2387 28.4765); + --destructive-foreground: oklch(0.9702 0 0); + --border: oklch(0.9219 0 0); + --input: oklch(0.9219 0 0); + --ring: oklch(0.709 0 0); + --chart-1: oklch(0.5555 0 0); + --chart-2: oklch(0.5555 0 0); + --chart-3: oklch(0.5555 0 0); + --chart-4: oklch(0.5555 0 0); + --chart-5: oklch(0.5555 0 0); + --sidebar: oklch(0.9851 0 0); + --sidebar-foreground: oklch(0.1448 0 0); + --sidebar-primary: oklch(0.2046 0 0); + --sidebar-primary-foreground: oklch(0.9851 0 0); + --sidebar-accent: oklch(0.9702 0 0); + --sidebar-accent-foreground: oklch(0.2046 0 0); + --sidebar-border: oklch(0.9219 0 0); + --sidebar-ring: oklch(0.709 0 0); + --font-sans: Geist Mono, monospace; + --font-serif: Geist Mono, monospace; + --font-mono: Geist Mono, monospace; + --radius: 0rem; + --shadow-x: 0px; + --shadow-y: 1px; + --shadow-blur: 0px; + --shadow-spread: 0px; + --shadow-opacity: 0; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); + --shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); + --shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0); + --shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0); + --shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px -1px hsl(0 0% 0% / 0); + --shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px -1px hsl(0 0% 0% / 0); + --shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px -1px hsl(0 0% 0% / 0); + --shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: oklch(0.1448 0 0); + --foreground: oklch(0.9851 0 0); + --card: oklch(0.2134 0 0); + --card-foreground: oklch(0.9851 0 0); + --popover: oklch(0.2686 0 0); + --popover-foreground: oklch(0.9851 0 0); + --primary: oklch(1 0 0); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.2686 0 0); + --secondary-foreground: oklch(0.9851 0 0); + --muted: oklch(0.2686 0 0); + --muted-foreground: oklch(0.709 0 0); + --accent: oklch(0.3715 0 0); + --accent-foreground: oklch(0.9851 0 0); + --destructive: oklch(0.7022 0.1892 22.2279); + --destructive-foreground: oklch(0.2686 0 0); + --border: oklch(0.3407 0 0); + --input: oklch(0.4386 0 0); + --ring: oklch(0.5555 0 0); + --chart-1: oklch(0.5555 0 0); + --chart-2: oklch(0.5555 0 0); + --chart-3: oklch(0.5555 0 0); + --chart-4: oklch(0.5555 0 0); + --chart-5: oklch(0.5555 0 0); + --sidebar: oklch(0.2046 0 0); + --sidebar-foreground: oklch(0.9851 0 0); + --sidebar-primary: oklch(0.9851 0 0); + --sidebar-primary-foreground: oklch(0.2046 0 0); + --sidebar-accent: oklch(0.2686 0 0); + --sidebar-accent-foreground: oklch(0.9851 0 0); + --sidebar-border: oklch(1 0 0); + --sidebar-ring: oklch(0.4386 0 0); + --font-sans: Geist Mono, monospace; + --font-serif: Geist Mono, monospace; + --font-mono: Geist Mono, monospace; + --radius: 0rem; + --shadow-x: 0px; + --shadow-y: 1px; + --shadow-blur: 0px; + --shadow-spread: 0px; + --shadow-opacity: 0; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); + --shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); + --shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0); + --shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0); + --shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px -1px hsl(0 0% 0% / 0); + --shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px -1px hsl(0 0% 0% / 0); + --shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px -1px hsl(0 0% 0% / 0); + --shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0); + } +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); +} + +.dark { + --sidebar: + hsl(240 5.9% 10%); + --sidebar-foreground: + hsl(240 4.8% 95.9%); + --sidebar-primary: + hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: + hsl(0 0% 100%); + --sidebar-accent: + hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: + hsl(240 4.8% 95.9%); + --sidebar-border: + hsl(240 3.7% 15.9%); + --sidebar-ring: + hsl(217.2 91.2% 59.8%); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/tracker/app/components/column/ColumnDialog.tsx b/packages/tracker/app/components/column/ColumnDialog.tsx new file mode 100644 index 0000000..a7f6697 --- /dev/null +++ b/packages/tracker/app/components/column/ColumnDialog.tsx @@ -0,0 +1,144 @@ +import { use, useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Trash2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; + +interface ColumnDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: { name: string; position?: number }) => Promise; + onDelete?: () => void; + columns: number; + initialData?: { + name?: string; + position?: number; + }; + isEditing?: boolean; +} + +export function ColumnDialog({ + open, + onOpenChange, + onSubmit, + onDelete, + initialData, + columns, + isEditing = false +}: ColumnDialogProps) { + const [name, setName] = useState(initialData?.name || ""); + const [position, setPosition] = useState(initialData?.position?.toString() || "0"); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (initialData?.name) { + setName(initialData.name); + } + if (initialData?.position !== undefined) { + setPosition(initialData.position.toString()); + } + }, [initialData?.name, initialData?.position, setName]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + setIsSubmitting(true); + try { + await onSubmit({ + name: name.trim(), + position: parseInt(position) || 0 + }); + onOpenChange(false); + // Reset form + setName(""); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = () => { + if (!onDelete) { + return; + } + setIsSubmitting(true); + try { + onDelete(); + onOpenChange(false); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + {isEditing ? "Edit Column" : "Create New Column"} + +
+
+ + setName(e.target.value)} + placeholder="Enter column name" + required + /> +
+ {isEditing && ( +
+ + +
+ )} + +
+
+ {isEditing && onDelete && ( + + )} +
+
+ +
+
+
+
+
+ ); +} diff --git a/packages/tracker/app/components/kanban/kanban.tsx b/packages/tracker/app/components/kanban/kanban.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/tracker/app/components/layout.tsx b/packages/tracker/app/components/layout.tsx new file mode 100644 index 0000000..67aacc8 --- /dev/null +++ b/packages/tracker/app/components/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/packages/tracker/app/components/project/ProjectDialog.tsx b/packages/tracker/app/components/project/ProjectDialog.tsx new file mode 100644 index 0000000..e1f4b9f --- /dev/null +++ b/packages/tracker/app/components/project/ProjectDialog.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; + +interface ProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: { + name: string; + description: string; + isPublic: boolean; + }) => void; + onDelete?: () => void; + initialData?: { + name: string; + description: string; + isPublic?: boolean; + }; + isEditing?: boolean; +} + +export function ProjectDialog({ + open, + onOpenChange, + onSubmit, + onDelete, + initialData, + isEditing = false, +}: ProjectDialogProps) { + const [name, setName] = useState(initialData?.name || ""); + const [description, setDescription] = useState(initialData?.description || ""); + const [isPublic, setIsPublic] = useState(initialData?.isPublic || false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + name, + description, + isPublic, + }); + onOpenChange(false); + // Reset form + if (!isEditing) { + setName(""); + setDescription(""); + } + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open && !isEditing) { + // Reset form when closing dialog in create mode + setName(""); + setDescription(""); + } + }; + + return ( + + +
+ + + {isEditing ? "Edit Project" : "Create Project"} + + + {isEditing + ? "Update your project details." + : "Create a new project to organize your tasks." + } + + +
+
+ + setName(e.target.value)} + placeholder="Enter project name" + required + /> +
+
+ +