first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.env
!.env.example
node_modules/
dist/
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /workspace
COPY package.json package-lock.json ./
COPY apps/frontend/package.json apps/frontend/package.json
COPY apps/backend/auth-service/package.json apps/backend/auth-service/package.json
COPY packages/api-client/package.json packages/api-client/package.json
COPY packages/openclaw-plugin/package.json packages/openclaw-plugin/package.json
RUN npm ci
FROM deps AS build
WORKDIR /workspace
ARG VITE_FRONTEND_URL=http://localhost:3000
ARG VITE_AUTH_URL=http://localhost:3001
ARG VITE_API_URL=http://localhost:8080
ARG VITE_DEV_MAILBOX_ENABLED=true
ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL}
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_DEV_MAILBOX_ENABLED=${VITE_DEV_MAILBOX_ENABLED}
COPY . .
RUN npm run gen:api && npm run build -w apps/frontend
FROM nginx:alpine AS runtime
WORKDIR /usr/share/nginx/html
COPY --from=build /workspace/apps/frontend/dist .
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+74
View File
@@ -0,0 +1,74 @@
# SolidStart → SolidJS + Vite Migration
## What Changed
### Removed
- `@solidjs/start` - Full-stack framework
- `vinxi` - Build tool
- `.vinxi/` and `.output/` build directories
- `entry-client.tsx` and `entry-server.tsx` - SSR entry points
- `app.config.ts` - SolidStart config
### Added
- `vite` - Fast build tool
- `vite-plugin-solid` - SolidJS plugin for Vite
- `index.html` - Standard HTML entry point
- `src/index.tsx` - Client-side entry point
- `vite.config.ts` - Vite configuration
- `nginx.conf` - Static file serving config
### Updated
- `package.json` - New scripts and dependencies
- `app.tsx` - Manual routing instead of FileRoutes
- `tsconfig.json` - Vite types instead of Vinxi
- `Dockerfile` - Static build with nginx instead of Node server
- `.gitignore` - Removed SolidStart artifacts
## Benefits
1. **Simpler**: No SSR complexity, just a clean SPA
2. **Faster**: Vite's dev server is lightning fast
3. **Cleaner**: Standard Vite setup everyone knows
4. **Smaller**: Static files served by nginx (much lighter than Node)
5. **Easier to debug**: No framework magic, just Vite + SolidJS
## How to Run
```bash
# Install dependencies
npm install
# Development
npm run dev
# Build
npm run build
# Preview production build
npm run preview
```
## Routing
Routes are now explicitly defined in `src/app.tsx` instead of file-based routing:
```tsx
<Route path="/app/:workspaceSlug">
<Route path="/today" component={TodayRoute} />
<Route path="/calendar" component={CalendarRoute} />
// ... etc
</Route>
```
All route components are lazy-loaded for optimal performance.
## Docker
The Dockerfile now builds static files and serves them with nginx on port 80 (instead of Node on port 3000).
## Notes
- Service worker registration still works
- All auth flows remain unchanged
- API proxy configured in vite.config.ts for `/api/auth`
- All existing components and pages work as-is
+30
View File
@@ -0,0 +1,30 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
+88
View File
@@ -0,0 +1,88 @@
# SolidJS + Vite Migration Complete
## Summary
The Productier frontend has been successfully migrated from SolidStart to pure SolidJS + Vite. The application is now running correctly with all features intact.
## What Was Changed
### 1. Vite Configuration (`vite.config.ts`)
- Updated the path resolution to use ES modules (`fileURLToPath` and `dirname`)
- Maintained the `~` alias for clean imports
- Kept the proxy configuration for the auth service
### 2. Package Configuration (`package.json`)
- Already configured correctly with:
- `solid-js` for the framework
- `vite-plugin-solid` for Vite integration
- `@solidjs/router` for routing
- No SolidStart dependencies
### 3. Application Structure
- Entry point: `src/index.tsx` - Uses `solid-js/web` render
- Main app: `src/app.tsx` - Pure SolidJS Router setup
- Routes: All routes in `src/routes/` are standard SolidJS components
- No server-side rendering (SSR) - Pure client-side app
## Running the Application
### Development Mode
1. Start the backend services:
```bash
docker compose up
```
2. Start the frontend dev server:
```bash
cd apps/frontend
npm run dev
```
3. Open http://localhost:5173
### Production Build
```bash
cd apps/frontend
npm run build
```
The build output will be in `apps/frontend/dist/`
## Services
- Frontend: http://localhost:5173 (Vite dev server)
- Auth Service: http://localhost:43001 (Node.js)
- API Service: http://localhost:48080 (Go)
- PostgreSQL: localhost:5432 (Docker)
## Key Features
- ✅ Hot Module Replacement (HMR) with Vite
- ✅ Fast refresh for SolidJS components
- ✅ TypeScript support
- ✅ Tailwind CSS v4
- ✅ Client-side routing with @solidjs/router
- ✅ Better Auth integration
- ✅ Offline-first architecture with local state
- ✅ Production build optimization
## Tech Stack
- **Framework**: SolidJS 1.9.5
- **Build Tool**: Vite 6.0.7
- **Router**: @solidjs/router 0.15.0
- **Styling**: Tailwind CSS 4.0.7
- **Auth**: Better Auth 1.5.6
- **Icons**: Lucide Solid
- **Date Handling**: date-fns
- **Markdown**: marked
## Notes
- The app is fully client-side rendered (no SSR)
- All routes are lazy-loaded for optimal performance
- The `~` alias resolves to `src/` directory
- Auth session is managed via Better Auth
- State management uses SolidJS stores
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Productier - Calm productivity workspace" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<title>Productier</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@productier/frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@productier/api-client": "*",
"@solidjs/router": "^0.15.0",
"better-auth": "^1.5.6",
"date-fns": "^4.1.0",
"lucide-solid": "^0.542.0",
"marked": "^16.3.0",
"solid-js": "^1.9.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@types/node": "^25.5.2",
"tailwindcss": "^4.0.7",
"vite": "^6.0.7",
"vite-plugin-solid": "^2.10.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

+17
View File
@@ -0,0 +1,17 @@
{
"name": "Productier",
"short_name": "Productier",
"description": "Calm planning, boards, notes, and focus sessions in a lightweight PWA.",
"start_url": "/",
"display": "standalone",
"background_color": "#f4efe8",
"theme_color": "#e57d7d",
"icons": [
{
"src": "/favicon.ico",
"sizes": "48x48",
"type": "image/x-icon"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
const CACHE_NAME = "productier-v1";
const PRECACHE = ["/", "/manifest.json", "/favicon.ico"];
self.addEventListener("install", event => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE)));
});
self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))),
),
);
});
self.addEventListener("fetch", event => {
if (event.request.method !== "GET") {
return;
}
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
return cached;
}
return fetch(event.request)
.then(response => {
const copy = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
return response;
})
.catch(() => caches.match("/"));
}),
);
});
+8
View File
@@ -0,0 +1,8 @@
# Productier Frontend Remote Deployment Environment
# These are build-time environment variables for the frontend
# Public URLs (REQUIRED - where your services are accessible)
VITE_FRONTEND_URL=https://your-frontend-domain.com
VITE_AUTH_URL=https://your-auth-domain.com
VITE_API_URL=https://your-api-domain.com
VITE_DEV_MAILBOX_ENABLED=false
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
import { Router, Route, useLocation, useNavigate } from "@solidjs/router";
import { createEffect, createSignal, on, onCleanup, Show, Suspense, lazy } from "solid-js";
import AppShell from "~/components/app-shell";
import { authClient } from "~/lib/auth-client";
import { AppProvider } from "~/lib/app-context";
import "./app.css";
// Lazy load routes
const LoginPage = lazy(() => import("~/routes/login"));
const NotFoundPage = lazy(() => import("~/routes/[...404]"));
const AcceptInvitePage = lazy(() => import("~/routes/accept-invite/[token]"));
const TodayRoute = lazy(() => import("~/routes/app/[workspaceSlug]/today"));
const CalendarRoute = lazy(() => import("~/routes/app/[workspaceSlug]/calendar"));
const BoardRoute = lazy(() => import("~/routes/app/[workspaceSlug]/board"));
const MailRoute = lazy(() => import("~/routes/app/[workspaceSlug]/mail"));
const NotesRoute = lazy(() => import("~/routes/app/[workspaceSlug]/notes"));
const FocusRoute = lazy(() => import("~/routes/app/[workspaceSlug]/focus"));
const SettingsRoute = lazy(() => import("~/routes/app/[workspaceSlug]/settings"));
function RootFrame(props: { children: unknown }) {
const location = useLocation();
const navigate = useNavigate();
const session = authClient.useSession();
const [sessionTimeoutReached, setSessionTimeoutReached] = createSignal(false);
let sessionTimeoutId: number | undefined;
const inApp = () => location.pathname.startsWith("/app/");
const inLogin = () => location.pathname === "/login";
createEffect(
on(
() => session().isPending,
isPending => {
if (!isPending) {
setSessionTimeoutReached(false);
if (sessionTimeoutId !== undefined) {
window.clearTimeout(sessionTimeoutId);
sessionTimeoutId = undefined;
}
return;
}
if (sessionTimeoutId !== undefined) {
return;
}
sessionTimeoutId = window.setTimeout(() => {
setSessionTimeoutReached(true);
sessionTimeoutId = undefined;
}, 2000);
},
{ defer: true }
)
);
onCleanup(() => {
if (sessionTimeoutId !== undefined) {
window.clearTimeout(sessionTimeoutId);
}
});
createEffect(() => {
const current = session();
if (current.isPending && !sessionTimeoutReached()) {
return;
}
if (inApp() && !current.data) {
navigate("/login", { replace: true });
return;
}
if (inLogin() && current.data) {
navigate("/app/personal/today", { replace: true });
}
});
const loadingGate = () => inApp() && session().isPending && !sessionTimeoutReached();
const canRenderApp = () => !inApp() || Boolean(session().data);
return (
<Show
when={!loadingGate()}
fallback={
<main class="flex min-h-screen items-center justify-center px-4">
<div class="app-surface w-full max-w-xl rounded-[2rem] p-8 text-center">
<p class="section-title">Securing Session</p>
<h1 class="mt-3 text-3xl font-extrabold tracking-[-0.05em]">Loading your workspace shell</h1>
<p class="mt-3 text-sm text-soft">Checking the active Better Auth session before opening Productier.</p>
</div>
</main>
}
>
<Show when={inApp()} fallback={<Suspense>{props.children}</Suspense>}>
<Show when={canRenderApp()}>
<AppShell>
<Suspense>{props.children}</Suspense>
</AppShell>
</Show>
</Show>
</Show>
);
}
export default function App() {
return (
<AppProvider>
<Router root={props => <RootFrame>{props.children}</RootFrame>}>
<Route path="/" component={() => {
const navigate = useNavigate();
createEffect(() => navigate("/login", { replace: true }));
return null;
}} />
<Route path="/login" component={LoginPage} />
<Route path="/accept-invite/:token" component={AcceptInvitePage} />
<Route path="/app/:workspaceSlug">
<Route path="/today" component={TodayRoute} />
<Route path="/calendar" component={CalendarRoute} />
<Route path="/board" component={BoardRoute} />
<Route path="/mail" component={MailRoute} />
<Route path="/notes" component={NotesRoute} />
<Route path="/focus" component={FocusRoute} />
<Route path="/settings" component={SettingsRoute} />
<Route path="/" component={() => {
const navigate = useNavigate();
const location = useLocation();
createEffect(() => {
const slug = location.pathname.split("/")[2];
navigate(`/app/${slug}/today`, { replace: true });
});
return null;
}} />
</Route>
<Route path="*" component={NotFoundPage} />
</Router>
</AppProvider>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { A, useLocation } from "@solidjs/router";
import {
Bell,
Building2,
Calendar,
CalendarDays,
Inbox,
LayoutGrid,
LayoutList,
Link,
LogOut,
Mail,
Moon,
NotebookText,
Search,
Settings,
Sun,
TimerReset,
Users,
Wifi,
WifiOff
} from "lucide-solid";
import { For, Show, type JSX } from "solid-js";
import { useApp } from "~/lib/app-context";
import { CommandPalette } from "./command-palette";
import { GlobalSearch } from "./global-search";
import { NotificationsDropdown } from "./notifications-dropdown";
const navItems = [
{ href: "today", label: "Today", icon: LayoutGrid },
{ href: "inbox", label: "Inbox", icon: Inbox },
{ href: "calendar", label: "Calendar", icon: CalendarDays },
{ href: "board", label: "Board", icon: Bell },
{ href: "list", label: "List", icon: LayoutList },
{ href: "timeline", label: "Timeline", icon: Calendar },
{ href: "mail", label: "Mail", icon: Mail },
{ href: "notes", label: "Notes", icon: NotebookText },
{ href: "contacts", label: "Contacts", icon: Users },
{ href: "companies", label: "Companies", icon: Building2 },
{ href: "focus", label: "Focus", icon: TimerReset },
{ href: "integrations", label: "Integrations", icon: Link },
{ href: "settings", label: "Settings", icon: Settings }
];
export default function AppShell(props: { children: JSX.Element }) {
const app = useApp();
const location = useLocation();
const workspaceSlug = () => location.pathname.split("/")[2] || app.primaryWorkspace()?.slug || "personal";
const workspace = () => app.workspaceForSlug(workspaceSlug()) ?? app.primaryWorkspace();
const pendingSyncCount = () => app.state.offlineQueue.filter(item => item.status !== "synced").length;
return (
<>
<CommandPalette />
<GlobalSearch />
<div class="min-h-screen flex" style="position: relative; z-index: 1;">
{/* Desktop Sidebar */}
<aside class="hidden w-72 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] lg:flex lg:flex-col relative">
{/* Decorative accent line */}
<div class="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-[var(--accent)] via-[var(--secondary)] to-transparent opacity-60" />
{/* Logo */}
<div class="border-b border-[var(--border)] px-7 py-6 relative">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--accent)] via-[var(--accent-hover)] to-[var(--secondary)] text-white text-base font-bold shadow-lg relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-tr from-transparent via-white/20 to-transparent" />
<span class="relative">P</span>
</div>
<div>
<span class="text-xl font-semibold tracking-tight block" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 144;">Productier</span>
<span class="text-[10px] font-medium tracking-wider uppercase text-[var(--text-muted)] block mt-0.5">Workspace</span>
</div>
</div>
</div>
{/* Workspace selector */}
<div class="border-b border-[var(--border)] px-6 py-5">
<div class="group flex items-center gap-3 rounded-2xl bg-gradient-to-br from-[var(--bg-subtle)] to-[var(--bg-muted)] px-5 py-4 transition-all duration-300 hover:shadow-md hover:scale-[1.02] cursor-pointer relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-[var(--accent)]/5 to-[var(--secondary)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--accent-subtle)] via-[var(--accent-muted)] to-[var(--secondary-subtle)] text-[var(--accent)] text-base font-bold shadow-sm relative z-10">
{workspace()?.name?.charAt(0) ?? "P"}
</div>
<div class="min-w-0 flex-1 relative z-10">
<p class="truncate text-sm font-semibold">{workspace()?.name}</p>
<p class="text-xs text-[var(--text-muted)] capitalize flex items-center gap-1.5 mt-0.5">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-[var(--success)]" />
{workspace()?.role}
</p>
</div>
</div>
</div>
{/* Navigation */}
<nav class="flex-1 overflow-y-auto scrollbar-thin p-5">
<div class="space-y-2">
<For each={navItems}>
{item => {
const Icon = item.icon;
const href = () => `/app/${workspaceSlug()}/${item.href}`;
const active = () => location.pathname === href();
return (
<A
href={href()}
class="group flex items-center gap-3.5 rounded-xl px-4 py-3.5 text-sm font-medium transition-all duration-200 relative overflow-hidden"
classList={{
"bg-gradient-to-r from-[var(--accent)] to-[var(--accent-hover)] text-white shadow-lg shadow-[var(--accent)]/20": active(),
"text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg-subtle)]": !active()
}}
>
<Show when={active()}>
<div class="absolute inset-0 bg-gradient-to-r from-white/10 to-transparent opacity-50" />
</Show>
<Icon size={20} class="relative z-10 transition-transform duration-200 group-hover:scale-110" />
<span class="relative z-10">{item.label}</span>
<Show when={active()}>
<div class="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-white/40 rounded-l-full" />
</Show>
</A>
);
}}
</For>
</div>
</nav>
{/* Sync status */}
<div class="border-t border-[var(--border)] px-6 py-4 bg-[var(--bg-subtle)]">
<div class="flex items-center gap-3 text-xs">
<Show when={app.online()} fallback={
<div class="flex items-center gap-2.5 px-3 py-2 rounded-lg bg-[var(--error-muted)] text-[var(--error)] w-full">
<WifiOff size={16} />
<span class="font-medium">Offline mode</span>
</div>
}>
<div class="flex items-center gap-2.5 px-3 py-2 rounded-lg w-full transition-colors duration-200"
classList={{
"bg-[var(--warning-muted)] text-[var(--warning)]": pendingSyncCount() > 0,
"bg-[var(--success-muted)] text-[var(--success)]": pendingSyncCount() === 0
}}
>
<div class={`status-dot ${pendingSyncCount() > 0 ? 'syncing' : 'online'}`} />
<span class="font-medium">
{pendingSyncCount() > 0 ? `Syncing ${pendingSyncCount()}` : "All synced"}
</span>
</div>
</Show>
</div>
</div>
{/* User section */}
<div class="border-t border-[var(--border)] px-6 py-5">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">{app.state.session?.name}</p>
<p class="truncate text-xs text-[var(--text-muted)] mt-0.5">{app.state.session?.email}</p>
</div>
<button
class="group flex h-10 w-10 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--error-muted)] hover:text-[var(--error)] hover:shadow-md hover:scale-105"
onClick={() => void app.logout()}
aria-label="Sign out"
>
<LogOut size={18} class="transition-transform duration-200 group-hover:translate-x-0.5" />
</button>
</div>
</div>
</aside>
{/* Main content area */}
<div class="flex min-w-0 flex-1 flex-col">
{/* Header */}
<header class="sticky top-0 z-30 border-b border-[var(--border)] glass backdrop-blur-xl">
<div class="flex h-16 items-center justify-between px-6 lg:px-8">
{/* Left: Page context */}
<div class="flex items-center gap-4">
<div class="hidden lg:block">
<h1 class="text-lg font-semibold tracking-tight" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 120;">{workspace()?.name}</h1>
</div>
<div class="lg:hidden flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--accent)] to-[var(--secondary)] text-white text-sm font-bold shadow-md">
P
</div>
<span class="text-base font-semibold" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 120;">Productier</span>
</div>
</div>
{/* Right: Actions */}
<div class="flex items-center gap-3">
<NotificationsDropdown />
<button
class="group flex items-center gap-2 px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] text-[var(--text-muted)] text-sm hover:bg-[var(--surface-hover)] transition-colors"
onClick={() => {
const event = new KeyboardEvent("keydown", { key: "/", metaKey: true });
window.dispatchEvent(event);
}}
>
<Search size={16} />
<span class="hidden sm:inline">Search</span>
<kbd class="hidden sm:inline px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-xs">/</kbd>
</button>
<Show when={pendingSyncCount() > 0}>
<span class="hidden rounded-full bg-[var(--warning-muted)] px-3.5 py-2 text-xs font-semibold text-[var(--warning)] sm:inline-flex items-center gap-2 shadow-sm">
<div class="status-dot syncing" />
{pendingSyncCount()} pending
</span>
</Show>
<button
class="group flex h-11 w-11 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--bg-subtle)] hover:text-[var(--text)] hover:shadow-md hover:scale-105"
onClick={() => app.setTheme(app.state.theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
<Show when={app.state.theme === "dark"} fallback={<Moon size={19} class="transition-transform duration-200 group-hover:rotate-12" />}>
<Sun size={19} class="transition-transform duration-200 group-hover:rotate-45" />
</Show>
</button>
</div>
</div>
</header>
{/* Main content */}
<main class="flex-1 overflow-y-auto scrollbar-thin p-6 lg:p-10">
<div class="mx-auto max-w-7xl">
{props.children}
</div>
</main>
{/* Mobile bottom navigation */}
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--border)] glass backdrop-blur-xl lg:hidden">
<div class="flex h-20 items-center justify-around px-2">
<For each={navItems.slice(0, 5)}>
{item => {
const Icon = item.icon;
const href = `/app/${workspaceSlug()}/${item.href}`;
const active = () => location.pathname === href;
return (
<A
href={href}
class="group flex flex-col items-center gap-1.5 rounded-xl px-5 py-2.5 transition-all duration-200"
classList={{
"text-[var(--accent)]": active(),
"text-[var(--text-muted)]": !active()
}}
>
<div class="relative">
<Icon size={22} class="transition-transform duration-200 group-active:scale-90" />
<Show when={active()}>
<div class="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-[var(--accent)]" />
</Show>
</div>
<span class="text-[10px] font-semibold tracking-wide">{item.label}</span>
</A>
);
}}
</For>
</div>
</nav>
</div>
</div>
</>
);
}
@@ -0,0 +1,154 @@
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { Search, X } from "lucide-solid";
import { useNavigate } from "@solidjs/router";
import { useApp } from "~/lib/app-context";
interface Command {
id: string;
label: string;
shortcut?: string;
action: () => void;
category: string;
}
export function CommandPalette() {
const app = useApp();
const navigate = useNavigate();
const [open, setOpen] = createSignal(false);
const [query, setQuery] = createSignal("");
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const commands: Command[] = [
// Navigation
{ id: "nav-today", label: "Go to Today", shortcut: "G T", action: () => navigate(`/app/${workspaceSlug()}/today`), category: "Navigation" },
{ id: "nav-board", label: "Go to Board", shortcut: "G B", action: () => navigate(`/app/${workspaceSlug()}/board`), category: "Navigation" },
{ id: "nav-calendar", label: "Go to Calendar", shortcut: "G C", action: () => navigate(`/app/${workspaceSlug()}/calendar`), category: "Navigation" },
{ id: "nav-notes", label: "Go to Notes", shortcut: "G N", action: () => navigate(`/app/${workspaceSlug()}/notes`), category: "Navigation" },
{ id: "nav-contacts", label: "Go to Contacts", action: () => navigate(`/app/${workspaceSlug()}/contacts`), category: "Navigation" },
{ id: "nav-companies", label: "Go to Companies", action: () => navigate(`/app/${workspaceSlug()}/companies`), category: "Navigation" },
{ id: "nav-inbox", label: "Go to Inbox", action: () => navigate(`/app/${workspaceSlug()}/inbox`), category: "Navigation" },
{ id: "nav-focus", label: "Go to Focus", action: () => navigate(`/app/${workspaceSlug()}/focus`), category: "Navigation" },
{ id: "nav-settings", label: "Go to Settings", action: () => navigate(`/app/${workspaceSlug()}/settings`), category: "Navigation" },
// Actions
{ id: "action-new-task", label: "Create new task", shortcut: "N T", action: () => navigate(`/app/${workspaceSlug()}/board`), category: "Actions" },
{ id: "action-new-event", label: "Create new event", action: () => navigate(`/app/${workspaceSlug()}/calendar`), category: "Actions" },
{ id: "action-new-contact", label: "Create new contact", action: () => navigate(`/app/${workspaceSlug()}/contacts`), category: "Actions" },
{ id: "action-new-company", label: "Create new company", action: () => navigate(`/app/${workspaceSlug()}/companies`), category: "Actions" },
{ id: "action-capture", label: "Quick capture", action: () => navigate(`/app/${workspaceSlug()}/inbox`), category: "Actions" },
// Edit
{ id: "edit-undo", label: "Undo", shortcut: "Ctrl+Z", action: () => app.undo(), category: "Edit" },
{ id: "edit-redo", label: "Redo", shortcut: "Ctrl+Shift+Z", action: () => app.redo(), category: "Edit" },
// Theme
{ id: "theme-toggle", label: "Toggle theme", action: () => app.toggleTheme(), category: "Preferences" },
];
const filteredCommands = () => {
const q = query().toLowerCase();
if (!q) return commands;
return commands.filter(c =>
c.label.toLowerCase().includes(q) ||
c.category.toLowerCase().includes(q)
);
};
const handleKeyDown = (e: KeyboardEvent) => {
// Open with Cmd/Ctrl + K
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(!open());
setQuery("");
}
// Close with Escape
if (e.key === "Escape" && open()) {
setOpen(false);
}
};
onMount(() => {
window.addEventListener("keydown", handleKeyDown);
});
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown);
});
const executeCommand = (cmd: Command) => {
cmd.action();
setOpen(false);
setQuery("");
};
return (
<Show when={open()}>
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
{/* Palette */}
<div class="relative w-full max-w-xl bg-[var(--surface)] rounded-xl shadow-2xl border border-[var(--border)] overflow-hidden">
{/* Search input */}
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--border)]">
<Search class="w-5 h-5 text-[var(--text-muted)]" />
<input
type="text"
value={query()}
onInput={e => setQuery(e.currentTarget.value)}
placeholder="Search commands..."
class="flex-1 bg-transparent text-lg outline-none"
autofocus
/>
<kbd class="px-2 py-0.5 rounded bg-[var(--surface-hover)] text-xs text-[var(--text-muted)]">
ESC
</kbd>
</div>
{/* Commands list */}
<div class="max-h-[60vh] overflow-auto p-2">
<Show when={filteredCommands().length === 0}>
<div class="text-center py-8 text-[var(--text-muted)]">
No commands found
</div>
</Show>
<For each={Object.groupBy(filteredCommands(), c => c.category)}>
{([category, cmds]) => (
<div class="mb-2">
<div class="px-2 py-1 text-xs font-medium text-[var(--text-muted)] uppercase">
{category}
</div>
<For each={cmds}>
{cmd => (
<button
onClick={() => executeCommand(cmd)}
class="w-full flex items-center justify-between px-3 py-2 rounded-lg hover:bg-[var(--surface-hover)] text-left"
>
<span>{cmd.label}</span>
<Show when={cmd.shortcut}>
<kbd class="px-2 py-0.5 rounded bg-[var(--surface-hover)] text-xs text-[var(--text-muted)]">
{cmd.shortcut}
</kbd>
</Show>
</button>
)}
</For>
</div>
)}
</For>
</div>
{/* Footer */}
<div class="px-4 py-2 border-t border-[var(--border)] text-xs text-[var(--text-muted)] flex items-center gap-4">
<span><kbd class="px-1.5 py-0.5 rounded bg-[var(--surface-hover)]"></kbd> Navigate</span>
<span><kbd class="px-1.5 py-0.5 rounded bg-[var(--surface-hover)]"></kbd> Select</span>
<span><kbd class="px-1.5 py-0.5 rounded bg-[var(--surface-hover)]">ESC</kbd> Close</span>
</div>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,178 @@
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { Search, X, CheckCircle, Calendar, FileText, User, Building2, Mail } from "lucide-solid";
import { useNavigate } from "@solidjs/router";
import { format, parseISO } from "date-fns";
import { useApp } from "~/lib/app-context";
interface SearchResult {
id: string;
type: "task" | "event" | "note" | "contact" | "company" | "email";
title: string;
subtitle?: string;
href: string;
}
export function GlobalSearch() {
const app = useApp();
const navigate = useNavigate();
const [open, setOpen] = createSignal(false);
const [query, setQuery] = createSignal("");
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const results = (): SearchResult[] => {
const q = query().toLowerCase().trim();
if (!q || q.length < 2) return [];
const results: SearchResult[] = [];
// Search tasks
for (const task of app.state.tasks) {
if (task.workspaceSlug !== workspaceSlug()) continue;
if (task.title.toLowerCase().includes(q) || task.description.toLowerCase().includes(q)) {
results.push({
id: task.id,
type: "task",
title: task.title,
subtitle: task.status.replace("_", " "),
href: `/app/${workspaceSlug()}/board`
});
}
}
// Search events
for (const event of app.state.events) {
if (event.workspaceSlug !== workspaceSlug()) continue;
if (event.title.toLowerCase().includes(q)) {
results.push({
id: event.id,
type: "event",
title: event.title,
subtitle: format(parseISO(event.startsAt), "MMM d, yyyy"),
href: `/app/${workspaceSlug()}/calendar`
});
}
}
// Search notes
for (const note of app.state.notes) {
if (note.workspaceSlug !== workspaceSlug()) continue;
if (note.title.toLowerCase().includes(q) || note.content.toLowerCase().includes(q)) {
results.push({
id: note.id,
type: "note",
title: note.title || "Untitled",
subtitle: note.content.slice(0, 50) + "...",
href: `/app/${workspaceSlug()}/notes`
});
}
}
return results.slice(0, 10);
};
const handleKeyDown = (e: KeyboardEvent) => {
// Open with Cmd/Ctrl + /
if ((e.metaKey || e.ctrlKey) && e.key === "/") {
e.preventDefault();
setOpen(!open());
setQuery("");
}
if (e.key === "Escape" && open()) {
setOpen(false);
}
};
onMount(() => {
window.addEventListener("keydown", handleKeyDown);
});
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown);
});
const getIcon = (type: SearchResult["type"]) => {
switch (type) {
case "task": return CheckCircle;
case "event": return Calendar;
case "note": return FileText;
case "contact": return User;
case "company": return Building2;
case "email": return Mail;
}
};
const selectResult = (result: SearchResult) => {
navigate(result.href);
setOpen(false);
setQuery("");
};
return (
<Show when={open()}>
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<div class="relative w-full max-w-xl bg-[var(--surface)] rounded-xl shadow-2xl border border-[var(--border)] overflow-hidden">
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--border)]">
<Search class="w-5 h-5 text-[var(--text-muted)]" />
<input
type="text"
value={query()}
onInput={e => setQuery(e.currentTarget.value)}
placeholder="Search tasks, events, notes..."
class="flex-1 bg-transparent text-lg outline-none"
autofocus
/>
<kbd class="px-2 py-0.5 rounded bg-[var(--surface-hover)] text-xs text-[var(--text-muted)]">
ESC
</kbd>
</div>
<div class="max-h-[50vh] overflow-auto p-2">
<Show when={query().length < 2}>
<div class="text-center py-8 text-[var(--text-muted)]">
Type at least 2 characters to search
</div>
</Show>
<Show when={query().length >= 2 && results().length === 0}>
<div class="text-center py-8 text-[var(--text-muted)]">
No results found for "{query()}"
</div>
</Show>
<For each={results()}>
{result => {
const Icon = getIcon(result.type);
return (
<button
onClick={() => selectResult(result)}
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-[var(--surface-hover)] text-left"
>
<Icon class="w-5 h-5 text-[var(--text-muted)]" />
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{result.title}</div>
<Show when={result.subtitle}>
<div class="text-sm text-[var(--text-muted)] truncate">
{result.subtitle}
</div>
</Show>
</div>
<span class="text-xs text-[var(--text-muted)] capitalize">
{result.type}
</span>
</button>
);
}}
</For>
</div>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,18 @@
import { createMemo } from "solid-js";
import { marked } from "marked";
interface MarkdownPreviewProps {
content: string;
}
export default function MarkdownPreview(props: MarkdownPreviewProps) {
const html = createMemo(() => marked.parse(props.content || ""));
return (
<div
class="prose prose-stone max-w-none text-sm leading-7 [&_h1]:text-2xl [&_h1]:font-extrabold [&_h2]:text-xl [&_h2]:font-bold [&_ul]:pl-5"
innerHTML={String(html())}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { Portal } from "solid-js/web";
import { Show, type JSX } from "solid-js";
import { X } from "lucide-solid";
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: JSX.Element;
}
export default function Modal(props: ModalProps) {
return (
<Show when={props.open}>
<Portal>
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{
background: 'rgba(0, 0, 0, 0.6)',
animation: 'fadeIn 0.2s ease-out'
}}
onClick={props.onClose}
>
<div
class="relative w-full max-w-lg rounded-2xl border border-[var(--border)] bg-[var(--surface)] shadow-2xl scale-in"
style={{
'box-shadow': '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.05)'
}}
onClick={e => e.stopPropagation()}
>
{/* Decorative gradient top border */}
<div class="absolute top-0 left-0 right-0 h-1 rounded-t-2xl bg-gradient-to-r from-[var(--accent)] via-[var(--secondary)] to-[var(--accent)] opacity-80" />
<div class="flex items-center justify-between border-b border-[var(--border)] px-6 py-4 bg-[var(--bg-subtle)]">
<h2 class="text-base font-semibold tracking-tight" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 120;">
{props.title}
</h2>
<button
class="group flex h-9 w-9 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--bg-muted)] hover:text-[var(--text)] hover:scale-110 hover:rotate-90"
onClick={props.onClose}
aria-label="Close"
>
<X size={18} class="transition-transform duration-200" />
</button>
</div>
<div class="max-h-[calc(100vh-200px)] overflow-y-auto scrollbar-thin p-6">
{props.children}
</div>
</div>
</div>
</Portal>
</Show>
);
}
@@ -0,0 +1,159 @@
import { createSignal, For, Show, onMount, onCleanup } from "solid-js";
import { Bell, Check, CheckCheck, X } from "lucide-solid";
import {
apiListNotifications,
apiMarkNotificationRead,
apiMarkAllNotificationsRead,
apiUnreadNotificationCount
} from "~/lib/api-integrations";
import type { Notification } from "~/lib/types-integrations";
import { formatPrettyDate } from "~/lib/utils";
export function NotificationsDropdown() {
const [open, setOpen] = createSignal(false);
const [notifications, setNotifications] = createSignal<Notification[]>([]);
const [unreadCount, setUnreadCount] = createSignal(0);
const [loading, setLoading] = createSignal(false);
const loadNotifications = async () => {
setLoading(true);
try {
const [notifs, count] = await Promise.all([
apiListNotifications(),
apiUnreadNotificationCount()
]);
setNotifications(notifs);
setUnreadCount(count);
} catch (e) {
console.error("Failed to load notifications", e);
}
setLoading(false);
};
const markRead = async (id: string) => {
await apiMarkNotificationRead(id);
setNotifications(notifications().map(n => n.id === id ? { ...n, read: true } : n));
setUnreadCount(Math.max(0, unreadCount() - 1));
};
const markAllRead = async () => {
await apiMarkAllNotificationsRead();
setNotifications(notifications().map(n => ({ ...n, read: true })));
setUnreadCount(0);
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".notifications-dropdown")) {
setOpen(false);
}
};
onMount(() => {
loadNotifications();
document.addEventListener("click", handleClickOutside);
});
onCleanup(() => {
document.removeEventListener("click", handleClickOutside);
});
const getNotificationIcon = (type: string) => {
switch (type) {
case "task_assigned": return "📋";
case "task_completed": return "✅";
case "mention": return "💬";
case "comment": return "💭";
case "invite_accepted": return "👋";
default: return "🔔";
}
};
return (
<div class="relative notifications-dropdown">
<button
onClick={(e) => {
e.stopPropagation();
setOpen(!open());
if (!open()) return;
loadNotifications();
}}
class="relative flex h-11 w-11 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--bg-subtle)] hover:text-[var(--text)] hover:shadow-md hover:scale-105"
aria-label="Notifications"
>
<Bell size={19} />
<Show when={unreadCount() > 0}>
<span class="absolute -top-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--accent)] text-white text-xs font-bold">
{unreadCount() > 9 ? "9+" : unreadCount()}
</span>
</Show>
</button>
<Show when={open()}>
<div class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-xl z-50">
{/* Header */}
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<h3 class="font-semibold">Notifications</h3>
<Show when={unreadCount() > 0}>
<button
onClick={markAllRead}
class="flex items-center gap-1 text-xs text-[var(--accent)] hover:underline"
>
<CheckCheck size={14} />
Mark all read
</button>
</Show>
</div>
{/* List */}
<div class="max-h-96 overflow-y-auto">
<Show when={loading()}>
<div class="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
Loading...
</div>
</Show>
<Show when={!loading() && notifications().length === 0}>
<div class="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
No notifications
</div>
</Show>
<For each={notifications()}>
{notification => (
<div
class={`px-4 py-3 border-b border-[var(--border)] last:border-b-0 transition-colors ${
!notification.read ? "bg-[var(--accent-muted)]" : ""
}`}
>
<div class="flex items-start gap-3">
<span class="text-lg">{getNotificationIcon(notification.type)}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{notification.title}</p>
<Show when={notification.body}>
<p class="text-xs text-[var(--text-muted)] mt-0.5 line-clamp-2">
{notification.body}
</p>
</Show>
<p class="text-xs text-[var(--text-subtle)] mt-1">
{formatPrettyDate(notification.createdAt)}
</p>
</div>
<Show when={!notification.read}>
<button
onClick={() => markRead(notification.id)}
class="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent)]"
title="Mark as read"
>
<Check size={14} />
</button>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
);
}
@@ -0,0 +1,135 @@
import { createSignal, For, Show, onMount, onCleanup, createEffect } from "solid-js";
import { apiListPresence, apiUpdatePresence, apiClearPresence } from "~/lib/api-integrations";
import type { Presence } from "~/lib/types-integrations";
import { useApp } from "~/lib/app-context";
interface PresenceIndicatorProps {
workspaceSlug: string;
entityType?: string;
entityId?: string;
refreshInterval?: number; // milliseconds, default 10000 (10 seconds)
}
export function PresenceIndicator(props: PresenceIndicatorProps) {
const app = useApp();
const [presences, setPresences] = createSignal<Presence[]>([]);
const [myPresence, setMyPresence] = createSignal<Presence | null>(null);
const refreshInterval = () => props.refreshInterval || 10000;
const loadPresence = async () => {
try {
const list = await apiListPresence(
props.workspaceSlug,
props.entityType || "",
props.entityId || ""
);
setPresences(list.filter(p => p.userEmail !== app.state.session?.email));
} catch (e) {
console.error("Failed to load presence", e);
}
};
const updateMyPresence = async () => {
if (!app.state.session) return;
try {
const presence = await apiUpdatePresence({
workspaceSlug: props.workspaceSlug,
userEmail: app.state.session.email,
userName: app.state.session.name,
entityType: props.entityType,
entityId: props.entityId
});
setMyPresence(presence);
} catch (e) {
console.error("Failed to update presence", e);
}
};
const clearMyPresence = async () => {
if (!app.state.session) return;
try {
await apiClearPresence(props.workspaceSlug);
} catch (e) {
console.error("Failed to clear presence", e);
}
};
// Update presence when entity changes
createEffect(() => {
const _ = props.entityId; // track dependency
const __ = props.entityType; // track dependency
updateMyPresence();
loadPresence();
});
onMount(() => {
updateMyPresence();
loadPresence();
// Refresh presence periodically for real-time updates
const interval = setInterval(() => {
loadPresence();
updateMyPresence(); // Keep our presence alive
}, refreshInterval());
onCleanup(() => {
clearInterval(interval);
clearMyPresence();
});
});
const getInitials = (name: string) => {
return name
.split(" ")
.map(n => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const getColor = (email: string) => {
const colors = [
"bg-red-500",
"bg-orange-500",
"bg-amber-500",
"bg-yellow-500",
"bg-lime-500",
"bg-green-500",
"bg-emerald-500",
"bg-teal-500",
"bg-cyan-500",
"bg-sky-500",
"bg-blue-500",
"bg-indigo-500",
"bg-violet-500",
"bg-purple-500",
"bg-fuchsia-500",
"bg-pink-500"
];
const hash = email.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
};
return (
<Show when={presences().length > 0}>
<div class="flex items-center gap-1">
<div class="flex -space-x-2">
<For each={presences().slice(0, 4)}>
{presence => (
<div
class={`w-7 h-7 rounded-full ${getColor(presence.userEmail)} flex items-center justify-center text-white text-xs font-medium ring-2 ring-[var(--surface)]`}
title={`${presence.userName} is viewing`}
>
{getInitials(presence.userName)}
</div>
)}
</For>
</div>
<Show when={presences().length > 4}>
<span class="text-xs text-[var(--text-muted)] ml-1">
+{presences().length - 4} more
</span>
</Show>
</div>
</Show>
);
}
@@ -0,0 +1,89 @@
import { createSignal, Show } from "solid-js";
import { Repeat } from "lucide-solid";
interface RecurringConfigProps {
value: string;
onChange: (rule: string) => void;
endDate?: string;
onEndDateChange: (date?: string) => void;
}
export function RecurringConfig(props: RecurringConfigProps) {
const [showOptions, setShowOptions] = createSignal(false);
const presets = [
{ label: "Daily", value: "FREQ=DAILY" },
{ label: "Weekdays", value: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" },
{ label: "Weekly", value: "FREQ=WEEKLY" },
{ label: "Bi-weekly", value: "FREQ=WEEKLY;INTERVAL=2" },
{ label: "Monthly", value: "FREQ=MONTHLY" },
{ label: "Quarterly", value: "FREQ=MONTHLY;INTERVAL=3" },
{ label: "Yearly", value: "FREQ=YEARLY" },
];
const getLabel = () => {
if (!props.value) return null;
const preset = presets.find(p => p.value === props.value);
return preset?.label || "Custom";
};
return (
<div class="relative">
<button
type="button"
onClick={() => setShowOptions(!showOptions())}
class={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-colors ${
props.value
? "border-[var(--accent)] text-[var(--accent)] bg-[var(--accent-muted)]"
: "border-[var(--border)] text-[var(--text-muted)] hover:bg-[var(--surface-hover)]"
}`}
>
<Repeat class="w-4 h-4" />
<Show when={getLabel()} fallback="Repeat">
{getLabel()}
</Show>
</button>
<Show when={showOptions()}>
<div class="absolute top-full left-0 mt-1 z-10 w-64 p-3 rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
<div class="space-y-1">
<button
type="button"
onClick={() => { props.onChange(""); setShowOptions(false); }}
class={`w-full text-left px-3 py-2 rounded-lg text-sm ${
!props.value ? "bg-[var(--accent-muted)] text-[var(--accent)]" : "hover:bg-[var(--surface-hover)]"
}`}
>
No repeat
</button>
{presets.map(preset => (
<button
type="button"
onClick={() => { props.onChange(preset.value); setShowOptions(false); }}
class={`w-full text-left px-3 py-2 rounded-lg text-sm ${
props.value === preset.value ? "bg-[var(--accent-muted)] text-[var(--accent)]" : "hover:bg-[var(--surface-hover)]"
}`}
>
{preset.label}
</button>
))}
</div>
<Show when={props.value}>
<div class="mt-3 pt-3 border-t border-[var(--border)]">
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1">
End date (optional)
</label>
<input
type="date"
value={props.endDate || ""}
onInput={e => props.onEndDateChange(e.currentTarget.value || undefined)}
class="w-full px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] text-sm"
/>
</div>
</Show>
</div>
</Show>
</div>
);
}
@@ -0,0 +1,324 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { Clock, Link, Repeat, Trash2, Users } from "lucide-solid";
import Modal from "~/components/modal";
import { TimeTracker } from "./time-tracker";
import { RecurringConfig } from "./recurring-config";
import { useApp } from "~/lib/app-context";
import { colorOptions, getColorStyle } from "~/lib/utils";
import { apiLinkContactToTask, apiUnlinkContactFromTask } from "~/lib/api-crm";
import { notifyMentionedUsers, hasMentions } from "~/lib/mentions";
import type { Task } from "~/lib/types";
import type { Contact } from "~/lib/types-crm";
interface TaskDetailModalProps {
task: Task | undefined;
workspaceSlug: string;
labels: { id: string; name: string; color: string }[];
contacts?: Contact[];
onClose: () => void;
onDelete?: (taskId: string) => void;
}
export function TaskDetailModal(props: TaskDetailModalProps) {
const app = useApp();
const [newComment, setNewComment] = createSignal("");
const [recurrenceRule, setRecurrenceRule] = createSignal(props.task?.recurrenceRule || "");
const [recurrenceEnd, setRecurrenceEnd] = createSignal<string | undefined>(undefined);
const [showContacts, setShowContacts] = createSignal(false);
const [selectedContactId, setSelectedContactId] = createSignal("");
const task = () => props.task;
const updateTask = (updates: Partial<Task>) => {
if (!task()) return;
app.updateTask(task()!.id, current => {
Object.assign(current, updates);
});
};
const handleAddComment = async () => {
if (!newComment().trim() || !task()) return;
const commentContent = newComment();
app.createTaskComment(task()!.id, commentContent);
// Check for @mentions and create notifications
if (hasMentions(commentContent) && app.state.session) {
const workspaceSlug = task()!.workspaceSlug;
const memberEmails = new Map(
app.state.members.map(m => [m.name?.toLowerCase() || '', m.email])
);
await notifyMentionedUsers(
workspaceSlug,
app.state.session.name,
"task",
task()!.id,
commentContent,
memberEmails
);
}
setNewComment("");
};
const handleLinkContact = async () => {
if (!selectedContactId() || !task()) return;
try {
await apiLinkContactToTask(selectedContactId(), task()!.id);
// Refresh contacts list would happen here
} catch (e) {
console.error("Failed to link contact", e);
}
setSelectedContactId("");
setShowContacts(false);
};
return (
<Modal
open={!!task()}
title={task()?.title ?? "Task"}
onClose={props.onClose}
>
<Show when={task()}>
<div class="space-y-6">
{/* Title */}
<div>
<label class="mb-1.5 block text-sm font-medium">Title</label>
<input
class="input-base w-full"
value={task()!.title}
onInput={e => updateTask({ title: e.currentTarget.value })}
/>
</div>
{/* Description */}
<div>
<label class="mb-1.5 block text-sm font-medium">Description</label>
<textarea
class="input-base min-h-24 resize-none w-full"
value={task()!.description}
onInput={e => updateTask({ description: e.currentTarget.value })}
/>
</div>
{/* Due date and Color */}
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium">Due date</label>
<input
class="input-base w-full"
type="datetime-local"
value={task()!.dueAt?.slice(0, 16) ?? ""}
onInput={e => updateTask({
dueAt: e.currentTarget.value ? new Date(e.currentTarget.value).toISOString() : undefined
})}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium">Color</label>
<select
class="input-base w-full"
value={task()!.color}
onInput={e => updateTask({ color: e.currentTarget.value })}
>
<For each={colorOptions}>
{option => <option value={option}>{option}</option>}
</For>
</select>
</div>
</div>
{/* Recurring */}
<div>
<div class="flex items-center gap-2 mb-2">
<Repeat class="w-4 h-4 text-[var(--text-muted)]" />
<label class="text-sm font-medium">Recurring</label>
</div>
<RecurringConfig
value={task()!.recurrenceRule || ""}
onChange={(rule) => {
updateTask({ recurrenceRule: rule });
}}
endDate={task()!.recurrenceEnd}
onEndDateChange={(date) => {
updateTask({ recurrenceEnd: date });
}}
/>
</div>
{/* Labels */}
<div>
<label class="mb-2 block text-sm font-medium">Labels</label>
<div class="flex flex-wrap gap-2">
<For each={props.labels}>
{label => {
const active = () => task()!.labelIds.includes(label.id);
return (
<button
class="rounded px-2 py-1 text-xs font-medium transition-colors"
classList={{ "bg-[var(--bg-muted)]": !active() }}
style={{
background: active() ? getColorStyle(label.color).bg : undefined,
color: active() ? getColorStyle(label.color).text : "var(--text-muted)"
}}
onClick={() => updateTask({
labelIds: active()
? task()!.labelIds.filter(id => id !== label.id)
: [...task()!.labelIds, label.id]
})}
>
{label.name}
</button>
);
}}
</For>
</div>
</div>
{/* Time Tracking */}
<div>
<div class="flex items-center gap-2 mb-2">
<Clock class="w-4 h-4 text-[var(--text-muted)]" />
<label class="text-sm font-medium">Time Tracking</label>
</div>
<TimeTracker
taskId={task()!.id}
workspaceSlug={props.workspaceSlug}
/>
</div>
{/* Contacts */}
<Show when={props.contacts && props.contacts.length > 0}>
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<Users class="w-4 h-4 text-[var(--text-muted)]" />
<label class="text-sm font-medium">Linked Contacts</label>
</div>
<button
onClick={() => setShowContacts(!showContacts())}
class="text-xs text-[var(--accent)] hover:underline"
>
+ Add contact
</button>
</div>
<Show when={showContacts()}>
<div class="flex gap-2 mb-2">
<select
class="input-base flex-1 text-sm"
value={selectedContactId()}
onChange={e => setSelectedContactId(e.currentTarget.value)}
>
<option value="">Select contact...</option>
<For each={props.contacts}>
{contact => <option value={contact.id}>{contact.firstName} {contact.lastName}</option>}
</For>
</select>
<button
onClick={handleLinkContact}
class="button-secondary px-3 py-1.5 text-sm"
disabled={!selectedContactId()}
>
Link
</button>
</div>
</Show>
</div>
</Show>
{/* Attachments */}
<div>
<label class="mb-1.5 block text-sm font-medium">Attachments</label>
<input
class="input-base w-full"
type="file"
multiple
onChange={e => app.attachFilesToTask(task()!.id, e.currentTarget.files)}
/>
<Show when={task()!.attachments.length > 0}>
<div class="mt-2 space-y-1">
<For each={task()!.attachments}>
{attachment => (
<div class="flex items-center justify-between gap-2 rounded-md bg-[var(--bg-subtle)] px-3 py-2 text-sm">
<a
class="min-w-0 flex-1 truncate"
href={attachment.dataUrl}
download={attachment.name}
>
{attachment.name}
</a>
<span class="text-xs text-[var(--text-muted)]">
{Math.round(attachment.size / 1024)} KB
</span>
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-muted)] hover:text-[var(--error)]"
onClick={() => app.removeTaskAttachment(task()!.id, attachment.id)}
>
<Trash2 size={14} />
</button>
</div>
)}
</For>
</div>
</Show>
</div>
{/* Comments */}
<div>
<label class="mb-2 block text-sm font-medium">Comments</label>
<div class="mb-2 space-y-2 max-h-40 overflow-y-auto">
<For each={task()!.comments}>
{comment => (
<div class="rounded-md bg-[var(--bg-subtle)] px-3 py-2">
<p class="text-xs font-medium">{comment.author}</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">{comment.content}</p>
</div>
)}
</For>
</div>
<div class="flex gap-2">
<input
class="input-base flex-1"
placeholder="Add a comment..."
value={newComment()}
onInput={e => setNewComment(e.currentTarget.value)}
onKeyDown={e => e.key === "Enter" && handleAddComment()}
/>
<button
class="button-secondary px-3 py-2 text-sm"
onClick={handleAddComment}
disabled={!newComment().trim()}
>
Send
</button>
</div>
</div>
{/* Actions */}
<div class="flex justify-between pt-4 border-t border-[var(--border)]">
<Show when={props.onDelete}>
<button
onClick={() => {
props.onDelete?.(task()!.id);
props.onClose();
}}
class="px-4 py-2 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-950 text-sm"
>
Delete Task
</button>
</Show>
<div class="flex gap-2 ml-auto">
<button
onClick={props.onClose}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)] text-sm"
>
Close
</button>
</div>
</div>
</div>
</Show>
</Modal>
);
}
@@ -0,0 +1,168 @@
import { createSignal, createMemo, For, Show, onCleanup, onMount } from "solid-js";
import { Play, Pause, Square, Clock } from "lucide-solid";
import { format, parseISO, differenceInSeconds } from "date-fns";
import { useApp } from "~/lib/app-context";
import {
apiListTimeEntries,
apiCreateTimeEntry,
apiUpdateTimeEntry,
apiDeleteTimeEntry
} from "~/lib/api-crm";
import type { TimeEntry } from "~/lib/types-crm";
interface TimeTrackerProps {
taskId?: string;
workspaceSlug: string;
}
export function TimeTracker(props: TimeTrackerProps) {
const app = useApp();
const [entries, setEntries] = createSignal<TimeEntry[]>([]);
const [activeEntry, setActiveEntry] = createSignal<TimeEntry | null>(null);
const [now, setNow] = createSignal(Date.now());
const [description, setDescription] = createSignal("");
let timer: number | null = null;
onMount(() => {
timer = window.setInterval(() => setNow(Date.now()), 1000);
loadEntries();
});
onCleanup(() => {
if (timer) window.clearInterval(timer);
});
const loadEntries = async () => {
try {
const list = await apiListTimeEntries(props.workspaceSlug);
setEntries(list.filter(e => !props.taskId || e.taskId === props.taskId));
// Find active entry (no end time)
const active = list.find(e => !e.endedAt && (!props.taskId || e.taskId === props.taskId));
if (active) {
setActiveEntry(active);
setDescription(active.description);
}
} catch (e) {
console.error("Failed to load time entries", e);
}
};
const startTimer = async () => {
const entry = await apiCreateTimeEntry({
workspaceSlug: props.workspaceSlug,
taskId: props.taskId,
description: description(),
startedAt: new Date().toISOString()
});
setActiveEntry(entry);
setEntries([entry, ...entries()]);
};
const stopTimer = async () => {
const entry = activeEntry();
if (!entry) return;
const updated = await apiUpdateTimeEntry(entry.id, {
endedAt: new Date().toISOString()
});
setActiveEntry(null);
setEntries(entries().map(e => e.id === updated.id ? updated : e));
};
const deleteEntry = async (id: string) => {
await apiDeleteTimeEntry(id);
setEntries(entries().filter(e => e.id !== id));
};
const elapsedSeconds = createMemo(() => {
const entry = activeEntry();
if (!entry) return 0;
return differenceInSeconds(new Date(), parseISO(entry.startedAt));
});
const formatDuration = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
};
const totalTime = createMemo(() => {
return entries()
.filter(e => e.endedAt)
.reduce((sum, e) => sum + e.durationSeconds, 0);
});
return (
<div class="space-y-4">
{/* Active timer */}
<div class="flex items-center gap-4 p-4 rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<Show when={activeEntry()} fallback={
<button
onClick={startTimer}
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
<Play class="w-4 h-4" />
Start Timer
</button>
}>
<button
onClick={stopTimer}
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500 text-white hover:opacity-90"
>
<Square class="w-4 h-4" />
Stop
</button>
<div class="flex-1">
<div class="text-2xl font-mono">{formatDuration(elapsedSeconds())}</div>
<input
type="text"
value={description()}
onInput={e => setDescription(e.currentTarget.value)}
placeholder="What are you working on?"
class="w-full bg-transparent text-sm text-[var(--text-muted)] outline-none"
/>
</div>
</Show>
</div>
{/* Total time */}
<div class="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Clock class="w-4 h-4" />
Total logged: {formatDuration(totalTime())}
</div>
{/* Time entries list */}
<Show when={entries().length > 0}>
<div class="space-y-2">
<h4 class="text-sm font-medium">Time Log</h4>
<For each={entries().filter(e => e.endedAt).slice(0, 5)}>
{entry => (
<div class="flex items-center justify-between p-3 rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div>
<div class="text-sm">{entry.description || "No description"}</div>
<div class="text-xs text-[var(--text-muted)]">
{format(parseISO(entry.startedAt), "MMM d, h:mm a")} {formatDuration(entry.durationSeconds)}
</div>
</div>
<button
onClick={() => deleteEntry(entry.id)}
class="text-[var(--text-muted)] hover:text-red-500"
>
<Square class="w-4 h-4" />
</button>
</div>
)}
</For>
</div>
</Show>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />
+26
View File
@@ -0,0 +1,26 @@
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./app";
import "./app.css";
// Service Worker registration
if ("serviceWorker" in navigator) {
if (import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => undefined);
});
} else {
void navigator.serviceWorker
.getRegistrations()
.then(registrations => Promise.all(registrations.map(registration => registration.unregister())))
.catch(() => undefined);
}
}
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
render(() => <App />, root);
+53
View File
@@ -0,0 +1,53 @@
import type { ActivityEntry } from "./types";
export type ActivityType = "all" | "task" | "board" | "calendar" | "note" | "focus" | "mail" | "invite" | "system";
const activityTypeLabels: Record<ActivityType, string> = {
all: "All",
task: "Tasks",
board: "Board",
calendar: "Calendar",
note: "Notes",
focus: "Focus",
mail: "Mail",
invite: "Invites",
system: "System"
};
export function listActivityTypeOptions(): Array<{ value: ActivityType; label: string }> {
return (Object.keys(activityTypeLabels) as ActivityType[]).map(value => ({
value,
label: activityTypeLabels[value]
}));
}
export function classifyActivity(entry: ActivityEntry): Exclude<ActivityType, "all"> {
const text = `${entry.title} ${entry.detail}`.toLowerCase().trim();
if (text.includes("invite")) {
return "invite";
}
if (text.includes("board")) {
return "board";
}
if (text.includes("mail") || text.includes("inbox") || text.includes("smtp") || text.includes("imap")) {
return "mail";
}
if (text.includes("calendar") || text.includes("event")) {
return "calendar";
}
if (text.includes("note")) {
return "note";
}
if (text.includes("focus") || text.includes("pomodoro")) {
return "focus";
}
if (text.includes("task")) {
return "task";
}
return "system";
}
export function getActivityTypeLabel(type: Exclude<ActivityType, "all">): string {
return activityTypeLabels[type];
}
+171
View File
@@ -0,0 +1,171 @@
import { apiFetch } from "./api";
import type {
Contact,
Company,
InboxItem,
TimeEntry,
SavedView,
CreateContactInput,
UpdateContactInput,
CreateCompanyInput,
UpdateCompanyInput,
CreateInboxItemInput,
CreateTimeEntryInput,
UpdateTimeEntryInput,
CreateSavedViewInput
} from "./types-crm";
// Contacts
export async function apiListContacts(workspaceSlug: string): Promise<Contact[]> {
const res = await apiFetch(`/v1/contacts?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateContact(input: CreateContactInput): Promise<Contact> {
const res = await apiFetch("/v1/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiUpdateContact(contactId: string, input: UpdateContactInput): Promise<Contact> {
const res = await apiFetch(`/v1/contacts/${contactId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteContact(contactId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}`, { method: "DELETE" });
}
export async function apiLinkContactToTask(contactId: string, taskId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}/tasks/${taskId}`, { method: "POST" });
}
export async function apiUnlinkContactFromTask(contactId: string, taskId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}/tasks/${taskId}`, { method: "DELETE" });
}
export async function apiLinkContactToEvent(contactId: string, eventId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}/events/${eventId}`, { method: "POST" });
}
// Companies
export async function apiListCompanies(workspaceSlug: string): Promise<Company[]> {
const res = await apiFetch(`/v1/companies?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateCompany(input: CreateCompanyInput): Promise<Company> {
const res = await apiFetch("/v1/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiUpdateCompany(companyId: string, input: UpdateCompanyInput): Promise<Company> {
const res = await apiFetch(`/v1/companies/${companyId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteCompany(companyId: string): Promise<void> {
await apiFetch(`/v1/companies/${companyId}`, { method: "DELETE" });
}
// Inbox
export async function apiListInboxItems(workspaceSlug: string): Promise<InboxItem[]> {
const res = await apiFetch(`/v1/inbox?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateInboxItem(input: CreateInboxItemInput): Promise<InboxItem> {
const res = await apiFetch("/v1/inbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiProcessInboxItem(itemId: string, entityType: string, entityId: string): Promise<void> {
await apiFetch(`/v1/inbox/${itemId}/process`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entityType, entityId })
});
}
export async function apiDeleteInboxItem(itemId: string): Promise<void> {
await apiFetch(`/v1/inbox/${itemId}`, { method: "DELETE" });
}
// Time entries
export async function apiListTimeEntries(workspaceSlug: string): Promise<TimeEntry[]> {
const res = await apiFetch(`/v1/time-entries?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateTimeEntry(input: CreateTimeEntryInput): Promise<TimeEntry> {
const res = await apiFetch("/v1/time-entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiUpdateTimeEntry(entryId: string, input: UpdateTimeEntryInput): Promise<TimeEntry> {
const res = await apiFetch(`/v1/time-entries/${entryId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteTimeEntry(entryId: string): Promise<void> {
await apiFetch(`/v1/time-entries/${entryId}`, { method: "DELETE" });
}
// Saved views
export async function apiListSavedViews(workspaceSlug: string, entityType: string): Promise<SavedView[]> {
const res = await apiFetch(`/v1/saved-views?workspaceSlug=${encodeURIComponent(workspaceSlug)}&entityType=${encodeURIComponent(entityType)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateSavedView(input: CreateSavedViewInput): Promise<SavedView> {
const res = await apiFetch("/v1/saved-views", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteSavedView(viewId: string): Promise<void> {
await apiFetch(`/v1/saved-views/${viewId}`, { method: "DELETE" });
}
+131
View File
@@ -0,0 +1,131 @@
import { apiFetch } from "./api";
import type {
Integration,
Webhook,
Notification,
Presence,
CreateIntegrationInput,
CreateWebhookInput,
UpdatePresenceInput,
CreateNotificationInput
} from "./types-integrations";
// Integrations
export async function apiListIntegrations(workspaceSlug: string): Promise<Integration[]> {
const res = await apiFetch(`/v1/integrations?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateIntegration(input: CreateIntegrationInput): Promise<Integration> {
const res = await apiFetch("/v1/integrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteIntegration(integrationId: string): Promise<void> {
await apiFetch(`/v1/integrations/${integrationId}`, { method: "DELETE" });
}
// Webhooks
export async function apiListWebhooks(workspaceSlug: string): Promise<Webhook[]> {
const res = await apiFetch(`/v1/webhooks?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateWebhook(input: CreateWebhookInput): Promise<Webhook> {
const res = await apiFetch("/v1/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...input, events: JSON.stringify(input.events || []) })
});
const data = await res.json();
return data.data;
}
export async function apiDeleteWebhook(webhookId: string): Promise<void> {
await apiFetch(`/v1/webhooks/${webhookId}`, { method: "DELETE" });
}
// Notifications
export async function apiListNotifications(): Promise<Notification[]> {
const res = await apiFetch("/v1/notifications");
const data = await res.json();
return data.data || [];
}
export async function apiMarkNotificationRead(notificationId: string): Promise<void> {
await apiFetch(`/v1/notifications/${notificationId}/read`, { method: "POST" });
}
export async function apiMarkAllNotificationsRead(): Promise<void> {
await apiFetch("/v1/notifications/read-all", { method: "POST" });
}
export async function apiUnreadNotificationCount(): Promise<number> {
const res = await apiFetch("/v1/notifications/unread-count");
const data = await res.json();
return data.count || 0;
}
// Presence
export async function apiUpdatePresence(input: UpdatePresenceInput): Promise<Presence> {
const res = await apiFetch("/v1/presence", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiListPresence(workspaceSlug: string, entityType?: string, entityId?: string): Promise<Presence[]> {
let url = `/v1/presence?workspaceSlug=${encodeURIComponent(workspaceSlug)}`;
if (entityType) url += `&entityType=${encodeURIComponent(entityType)}`;
if (entityId) url += `&entityId=${encodeURIComponent(entityId)}`;
const res = await apiFetch(url);
const data = await res.json();
return data.data || [];
}
export async function apiClearPresence(workspaceSlug: string): Promise<void> {
await apiFetch(`/v1/presence?workspaceSlug=${encodeURIComponent(workspaceSlug)}`, { method: "DELETE" });
}
// Create notification
export async function apiCreateNotification(input: CreateNotificationInput): Promise<Notification> {
const res = await apiFetch("/v1/notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
// OAuth
export async function apiConnectGoogleCalendar(workspaceSlug: string, redirect?: string): Promise<OAuthConnectResponse> {
const params = new URLSearchParams({ workspaceSlug });
if (redirect) params.append("redirect", redirect);
const res = await apiFetch(`/v1/oauth/google-calendar/connect?${params.toString()}`);
const data = await res.json();
return data;
}
export async function apiConnectSlack(workspaceSlug: string, redirect?: string): Promise<OAuthConnectResponse> {
const params = new URLSearchParams({ workspaceSlug });
if (redirect) params.append("redirect", redirect);
const res = await apiFetch(`/v1/oauth/slack/connect?${params.toString()}`);
const data = await res.json();
return data;
}
export async function apiDisconnectIntegration(integrationId: string): Promise<void> {
await apiFetch(`/v1/integrations/${integrationId}/disconnect`, { method: "POST" });
}
import type { OAuthConnectResponse } from "./types-integrations";
+294
View File
@@ -0,0 +1,294 @@
import {
acceptInvite as acceptInviteRequest,
connectMailbox as connectMailboxRequest,
createBoardGroup as createBoardGroupRequest,
createCalendarEvent,
createClient,
createFocusSession as createFocusSessionRequest,
createInvite as createInviteRequest,
createLabel as createLabelRequest,
createNote as createNoteRequest,
createOutgoingMail as createOutgoingMailRequest,
createTask as createTaskRequest,
createTaskFromMail as createTaskFromMailRequest,
deleteTaskAttachment as deleteTaskAttachmentRequest,
getInviteByToken as getInviteByTokenRequest,
listActivity,
listBoardGroups,
listCalendarEvents,
listFocusSessions,
listInvites,
listLabels,
listMailMessages as listMailMessagesRequest,
listMailboxes as listMailboxesRequest,
listMembers,
listNotes,
listOutgoingMails as listOutgoingMailsRequest,
listTasks,
listWorkspaces,
revokeInvite as revokeInviteRequest,
syncMailbox as syncMailboxRequest,
uploadTaskAttachment as uploadTaskAttachmentRequest,
updateBoardGroup as updateBoardGroupRequest,
updateCalendarEvent,
updateFocusSession as updateFocusSessionRequest,
updateMember as updateMemberRequest,
updateNote as updateNoteRequest,
updateTask as updateTaskRequest
} from "@productier/api-client";
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:48080";
const apiClient = createClient({
baseUrl: API_BASE_URL
});
// Helper for custom API routes not in the generated client
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
...init?.headers
}
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res;
}
export async function fetchWorkspaceBundle(workspaceSlug: string) {
const [workspaces, members, invites, boardGroups, labels, tasks, events, notes, focusSessions, activity] = await Promise.all([
listWorkspaces({ client: apiClient, responseStyle: "data" }),
listMembers({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listInvites({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listBoardGroups({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listLabels({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listTasks({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listCalendarEvents({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listNotes({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listFocusSessions({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listActivity({ client: apiClient, responseStyle: "data", query: { workspaceSlug, limit: 40 } })
]);
return {
workspaces: workspaces?.data ?? [],
members: members?.data ?? [],
invites: invites?.data ?? [],
boardGroups: boardGroups?.data ?? [],
labels: labels?.data ?? [],
tasks: tasks?.data ?? [],
events: events?.data ?? [],
notes: notes?.data ?? [],
focusSessions: focusSessions?.data ?? [],
activities: activity?.data ?? []
};
}
export async function apiGetInviteByToken(token: string) {
const response = await getInviteByTokenRequest({
client: apiClient,
responseStyle: "data",
path: { token }
});
return response?.data;
}
export async function apiCreateInvite(body: Parameters<typeof createInviteRequest>[0]["body"]) {
const response = await createInviteRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiRevokeInvite(inviteId: string) {
await revokeInviteRequest({
client: apiClient,
responseStyle: "data",
path: { inviteId }
});
}
export async function apiAcceptInvite(token: string, body: Parameters<typeof acceptInviteRequest>[0]["body"]) {
const response = await acceptInviteRequest({
client: apiClient,
responseStyle: "data",
path: { token },
body
});
return response?.data;
}
export async function apiCreateTask(body: Parameters<typeof createTaskRequest>[0]["body"]) {
const response = await createTaskRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateTask(taskId: string, body: Parameters<typeof updateTaskRequest>[0]["body"]) {
const response = await updateTaskRequest({
client: apiClient,
responseStyle: "data",
path: { taskId },
body
});
return response?.data;
}
export async function apiUpdateMember(memberId: string, body: Parameters<typeof updateMemberRequest>[0]["body"]) {
const response = await updateMemberRequest({
client: apiClient,
responseStyle: "data",
path: { memberId },
body
});
return response?.data;
}
export async function apiCreateEvent(body: Parameters<typeof createCalendarEvent>[0]["body"]) {
const response = await createCalendarEvent({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateEvent(eventId: string, body: Parameters<typeof updateCalendarEvent>[0]["body"]) {
const response = await updateCalendarEvent({
client: apiClient,
responseStyle: "data",
path: { eventId },
body
});
return response?.data;
}
export async function apiCreateNote(body: Parameters<typeof createNoteRequest>[0]["body"]) {
const response = await createNoteRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateNote(noteId: string, body: Parameters<typeof updateNoteRequest>[0]["body"]) {
const response = await updateNoteRequest({
client: apiClient,
responseStyle: "data",
path: { noteId },
body
});
return response?.data;
}
export async function apiCreateBoardGroup(body: Parameters<typeof createBoardGroupRequest>[0]["body"]) {
const response = await createBoardGroupRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateBoardGroup(groupId: string, body: Parameters<typeof updateBoardGroupRequest>[0]["body"]) {
const response = await updateBoardGroupRequest({
client: apiClient,
responseStyle: "data",
path: { groupId },
body
});
return response?.data;
}
export async function apiUploadTaskAttachment(taskId: string, file: File) {
const formData = new FormData();
formData.set("file", file);
const response = await uploadTaskAttachmentRequest({
client: apiClient,
responseStyle: "data",
path: { taskId },
body: formData
});
return response?.data;
}
export async function apiDeleteTaskAttachment(taskId: string, attachmentId: string) {
await deleteTaskAttachmentRequest({
client: apiClient,
responseStyle: "data",
path: { taskId, attachmentId }
});
}
export async function apiCreateLabel(body: Parameters<typeof createLabelRequest>[0]["body"]) {
const response = await createLabelRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiCreateFocusSession(body: Parameters<typeof createFocusSessionRequest>[0]["body"]) {
const response = await createFocusSessionRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateFocusSession(sessionId: string, body: Parameters<typeof updateFocusSessionRequest>[0]["body"]) {
const response = await updateFocusSessionRequest({
client: apiClient,
responseStyle: "data",
path: { sessionId },
body
});
return response?.data;
}
export async function apiListMailboxes(workspaceSlug: string) {
const response = await listMailboxesRequest({
client: apiClient,
responseStyle: "data",
query: { workspaceSlug }
});
return response?.data ?? [];
}
export async function apiConnectMailbox(body: Parameters<typeof connectMailboxRequest>[0]["body"]) {
const response = await connectMailboxRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiSyncMailbox(mailboxId: string) {
const response = await syncMailboxRequest({
client: apiClient,
responseStyle: "data",
path: { mailboxId }
});
return response?.data;
}
export async function apiListMailMessages(workspaceSlug: string, mailboxId?: string) {
const response = await listMailMessagesRequest({
client: apiClient,
responseStyle: "data",
query: {
workspaceSlug,
...(mailboxId ? { mailboxId } : {})
}
});
return response?.data ?? [];
}
export async function apiListOutgoingMails(workspaceSlug: string, mailboxId?: string) {
const response = await listOutgoingMailsRequest({
client: apiClient,
responseStyle: "data",
query: {
workspaceSlug,
...(mailboxId ? { mailboxId } : {})
}
});
return response?.data ?? [];
}
export async function apiCreateOutgoingMail(body: Parameters<typeof createOutgoingMailRequest>[0]["body"]) {
const response = await createOutgoingMailRequest({
client: apiClient,
responseStyle: "data",
body
});
return response?.data;
}
export async function apiCreateTaskFromMail(messageId: string, body: Parameters<typeof createTaskFromMailRequest>[0]["body"]) {
const response = await createTaskFromMailRequest({
client: apiClient,
responseStyle: "data",
path: { messageId },
body
});
return response?.data;
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { createAuthClient } from "better-auth/solid";
import { magicLinkClient } from "better-auth/client/plugins";
export const authBaseUrl = import.meta.env.VITE_AUTH_URL || "http://localhost:43001";
export const authClient = createAuthClient({
baseURL: authBaseUrl,
plugins: [magicLinkClient()]
});
+98
View File
@@ -0,0 +1,98 @@
import { apiCreateNotification } from "./api-integrations";
import type { NotificationType } from "./types-integrations";
// Regex to match @mentions in text
const MENTION_REGEX = /@([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9_]+)/g;
export interface Mention {
text: string; // The full @mention text
identifier: string; // The identifier after @ (email or username)
startIndex: number;
endIndex: number;
}
/**
* Parse @mentions from text content
*/
export function parseMentions(text: string): Mention[] {
const mentions: Mention[] = [];
let match;
while ((match = MENTION_REGEX.exec(text)) !== null) {
mentions.push({
text: match[0],
identifier: match[1],
startIndex: match.index,
endIndex: match.index + match[0].length
});
}
return mentions;
}
/**
* Extract unique identifiers from mentions
*/
export function extractMentionIdentifiers(text: string): string[] {
const mentions = parseMentions(text);
return [...new Set(mentions.map(m => m.identifier))];
}
/**
* Check if text contains any mentions
*/
export function hasMentions(text: string): boolean {
return MENTION_REGEX.test(text);
}
/**
* Create notifications for mentioned users
* @param workspaceSlug - The workspace slug
* @param mentionerName - Name of the person who mentioned
* @param entityType - Type of entity (task, note, etc.)
* @param entityId - ID of the entity
* @param content - The content containing mentions
* @param knownEmails - Map of usernames to emails if available
*/
export async function notifyMentionedUsers(
workspaceSlug: string,
mentionerName: string,
entityType: string,
entityId: string,
content: string,
knownEmails?: Map<string, string>
): Promise<void> {
const identifiers = extractMentionIdentifiers(content);
for (const identifier of identifiers) {
// If identifier looks like an email, use it directly
// Otherwise, look it up in knownEmails map
const email = identifier.includes('@')
? identifier
: knownEmails?.get(identifier.toLowerCase());
if (!email) continue;
try {
await apiCreateNotification({
workspaceSlug,
userEmail: email,
type: "mention" as NotificationType,
title: `${mentionerName} mentioned you`,
body: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
entityType,
entityId
});
} catch (e) {
console.error('Failed to create mention notification:', e);
}
}
}
/**
* Highlight mentions in text for display
* Returns HTML string with mentions wrapped in spans
*/
export function highlightMentions(text: string): string {
return text.replace(MENTION_REGEX, '<span class="mention text-[var(--accent)] font-medium">$&</span>');
}
+254
View File
@@ -0,0 +1,254 @@
import { addDays, addMinutes, formatISO, setHours, setMinutes, subHours } from "date-fns";
import type { AppState } from "./types";
const now = new Date();
const todayMorning = setMinutes(setHours(now, 9), 30);
const reviewStart = setMinutes(setHours(now, 13), 0);
const reviewEnd = setMinutes(setHours(now, 14), 0);
export function createSeedState(): AppState {
return {
theme: "light",
session: {
id: "user-1",
name: "Taylor",
email: "taylor@productier.app"
},
workspaces: [
{
id: "workspace-1",
slug: "personal",
name: "Personal HQ",
role: "owner",
createdAt: formatISO(subHours(now, 72))
}
],
members: [
{
id: "member-1",
workspaceSlug: "personal",
name: "Taylor",
email: "taylor@productier.app",
role: "owner",
status: "active"
},
{
id: "member-2",
workspaceSlug: "personal",
name: "Alex",
email: "alex@productier.app",
role: "member",
status: "active"
}
],
invites: [
{
id: "invite-1",
workspaceSlug: "personal",
email: "jamie@productier.app",
role: "member",
token: "invite-jamie",
createdAt: formatISO(subHours(now, 12)),
status: "pending"
}
],
boardGroups: [
{ id: "group-inbox", workspaceSlug: "personal", name: "Inbox", color: "slate", order: 0 },
{ id: "group-doing", workspaceSlug: "personal", name: "Doing", color: "rose", order: 1 },
{ id: "group-review", workspaceSlug: "personal", name: "Review", color: "amber", order: 2 },
{ id: "group-done", workspaceSlug: "personal", name: "Done", color: "emerald", order: 3 }
],
labels: [
{ id: "label-launch", workspaceSlug: "personal", name: "Launch", color: "rose" },
{ id: "label-ui", workspaceSlug: "personal", name: "UI", color: "amber" },
{ id: "label-deep", workspaceSlug: "personal", name: "Deep Work", color: "emerald" },
{ id: "label-admin", workspaceSlug: "personal", name: "Admin", color: "blue" }
],
tasks: [
{
id: "task-foundation",
workspaceSlug: "personal",
boardGroupId: "group-doing",
title: "Finalize Productier foundation",
description:
"Align the web shell, board, and calendar so the app feels calm, premium, and mobile-ready.",
status: "in_progress",
color: "rose",
dueAt: formatISO(addDays(now, 1)),
scheduledStart: formatISO(todayMorning),
scheduledEnd: formatISO(addMinutes(todayMorning, 90)),
assigneeId: "member-1",
labelIds: ["label-launch", "label-ui"],
attachments: [],
comments: [
{
id: "comment-1",
taskId: "task-foundation",
author: "Alex",
content: "Keep the calendar dense, but not cramped.",
createdAt: formatISO(subHours(now, 5))
}
],
recurrenceRule: "",
archived: false,
createdAt: formatISO(subHours(now, 24)),
updatedAt: formatISO(subHours(now, 2))
},
{
id: "task-copy",
workspaceSlug: "personal",
boardGroupId: "group-inbox",
title: "Refine onboarding copy",
description: "Make the empty states feel useful without becoming noisy.",
status: "todo",
color: "slate",
assigneeId: "member-2",
labelIds: ["label-admin"],
attachments: [],
comments: [],
recurrenceRule: "",
archived: false,
createdAt: formatISO(subHours(now, 12)),
updatedAt: formatISO(subHours(now, 12))
},
{
id: "task-sync",
workspaceSlug: "personal",
boardGroupId: "group-review",
title: "Review offline queue behavior",
description: "Preserve local drafts when queued edits would conflict after reconnect.",
status: "todo",
color: "amber",
labelIds: ["label-launch"],
attachments: [],
comments: [],
recurrenceRule: "",
archived: false,
createdAt: formatISO(subHours(now, 8)),
updatedAt: formatISO(subHours(now, 4))
}
],
events: [
{
id: "event-review",
workspaceSlug: "personal",
title: "Design review",
description: "Tighten spacing rhythm and modal hierarchy.",
startsAt: formatISO(reviewStart),
endsAt: formatISO(reviewEnd),
color: "amber",
linkedTaskId: "task-foundation",
attachments: []
},
{
id: "event-checkin",
workspaceSlug: "personal",
title: "Weekly check-in",
description: "Review priorities for the next sprint.",
startsAt: formatISO(addDays(reviewStart, 2)),
endsAt: formatISO(addDays(reviewEnd, 2)),
color: "blue",
attachments: []
}
],
notes: [
{
id: "note-direction",
workspaceSlug: "personal",
title: "Calm UI direction",
content:
"# Design guardrails\n\n- Keep the board readable on small screens.\n- Use one accent hue at a time.\n- Treat the calendar as a planning surface, not a spreadsheet.",
updatedAt: formatISO(subHours(now, 3))
},
{
id: "note-roadmap",
workspaceSlug: "personal",
title: "Phase outline",
content:
"## Phase 1\n\nFoundation, auth shell, board/calendar basics.\n\n## Phase 2\n\nNotes, focus, attachments, offline queue.",
updatedAt: formatISO(subHours(now, 20))
}
],
focusSessions: [
{
id: "focus-complete",
workspaceSlug: "personal",
taskId: "task-foundation",
mode: "focus",
startedAt: formatISO(subHours(now, 2)),
completedAt: formatISO(subHours(now, 1.5)),
pausedTotalSeconds: 0,
durationSeconds: 1500
}
],
activities: [
{
id: "activity-1",
workspaceSlug: "personal",
title: "Board updated",
detail: "Task moved into Review.",
createdAt: formatISO(subHours(now, 4))
},
{
id: "activity-2",
workspaceSlug: "personal",
title: "Invite created",
detail: "Jamie was invited to Personal HQ.",
createdAt: formatISO(subHours(now, 10))
}
],
offlineQueue: [],
// CRM
contacts: [
{
id: "contact-1",
workspaceSlug: "personal",
firstName: "Sarah",
lastName: "Chen",
email: "sarah@example.com",
phone: "+1-555-0100",
title: "Product Manager",
notes: "Main point of contact for the integration project",
avatarUrl: "",
createdAt: formatISO(subHours(now, 48)),
updatedAt: formatISO(subHours(now, 48))
},
{
id: "contact-2",
workspaceSlug: "personal",
firstName: "Marcus",
lastName: "Rivera",
email: "marcus@example.com",
phone: "+1-555-0101",
title: "Engineering Lead",
notes: "",
avatarUrl: "",
createdAt: formatISO(subHours(now, 24)),
updatedAt: formatISO(subHours(now, 24))
}
],
companies: [
{
id: "company-1",
workspaceSlug: "personal",
name: "Acme Corp",
domain: "acme.com",
website: "https://acme.com",
industry: "Technology",
size: "51-200",
notes: "Enterprise customer, priority support",
logoUrl: "",
createdAt: formatISO(subHours(now, 72)),
updatedAt: formatISO(subHours(now, 72))
}
],
inboxItems: [],
timeEntries: [],
savedViews: [],
// Integrations
integrations: [],
webhooks: [],
notifications: []
};
}
+135
View File
@@ -0,0 +1,135 @@
// CRM Types
export interface Contact {
id: string;
workspaceSlug: string;
firstName: string;
lastName: string;
email: string;
phone: string;
companyId?: string;
companyName?: string;
title: string;
notes: string;
avatarUrl: string;
createdAt: string;
updatedAt: string;
}
export interface Company {
id: string;
workspaceSlug: string;
name: string;
domain: string;
website: string;
industry: string;
size: string;
notes: string;
logoUrl: string;
createdAt: string;
updatedAt: string;
}
export interface InboxItem {
id: string;
workspaceSlug: string;
content: string;
source: string;
processed: boolean;
processedAt?: string;
processedEntityType?: string;
processedEntityId?: string;
createdAt: string;
}
export interface TimeEntry {
id: string;
workspaceSlug: string;
taskId?: string;
description: string;
startedAt: string;
endedAt?: string;
durationSeconds: number;
createdAt: string;
updatedAt: string;
}
export interface SavedView {
id: string;
workspaceSlug: string;
name: string;
entityType: string;
filterJson: string;
sortJson: string;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateContactInput {
workspaceSlug: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
companyId?: string;
title?: string;
notes?: string;
}
export interface UpdateContactInput {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
companyId?: string;
title?: string;
notes?: string;
}
export interface CreateCompanyInput {
workspaceSlug: string;
name: string;
domain?: string;
website?: string;
industry?: string;
size?: string;
notes?: string;
}
export interface UpdateCompanyInput {
name?: string;
domain?: string;
website?: string;
industry?: string;
size?: string;
notes?: string;
}
export interface CreateInboxItemInput {
workspaceSlug: string;
content: string;
source?: string;
}
export interface CreateTimeEntryInput {
workspaceSlug: string;
taskId?: string;
description?: string;
startedAt: string;
endedAt?: string;
}
export interface UpdateTimeEntryInput {
description?: string;
endedAt?: string;
}
export interface CreateSavedViewInput {
workspaceSlug: string;
name: string;
entityType: string;
filterJson?: string;
sortJson?: string;
isDefault?: boolean;
}
+104
View File
@@ -0,0 +1,104 @@
// Integration types
export interface Integration {
id: string;
workspaceSlug: string;
provider: string;
name: string;
config: string;
status: string;
lastSyncAt?: string;
createdAt: string;
updatedAt: string;
}
export interface Webhook {
id: string;
workspaceSlug: string;
name: string;
url: string;
events: string[];
active: boolean;
lastTriggeredAt?: string;
createdAt: string;
updatedAt: string;
}
export interface Notification {
id: string;
workspaceSlug: string;
userEmail: string;
type: string;
title: string;
body: string;
entityType?: string;
entityId?: string;
read: boolean;
createdAt: string;
}
export interface Presence {
id: string;
workspaceSlug: string;
userEmail: string;
userName: string;
entityType?: string;
entityId?: string;
lastSeenAt: string;
createdAt: string;
}
export interface CreateIntegrationInput {
workspaceSlug: string;
provider: string;
name: string;
config?: string;
credentials: string;
}
export interface CreateWebhookInput {
workspaceSlug: string;
name: string;
url: string;
events?: string[];
}
export interface UpdatePresenceInput {
workspaceSlug: string;
userEmail: string;
userName: string;
entityType?: string;
entityId?: string;
}
export interface CreateNotificationInput {
workspaceSlug: string;
userEmail: string;
type: NotificationType;
title: string;
body?: string;
entityType?: string;
entityId?: string;
}
export type NotificationType =
| "task_assigned"
| "task_completed"
| "mention"
| "comment"
| "invite_accepted"
| "event_reminder";
export type IntegrationProvider =
| "google_calendar"
| "slack"
| "discord"
| "webhook";
export interface OAuthConnectResponse {
authUrl: string;
}
export interface OAuthCallbackResult {
connected: string;
integrationId?: string;
}
+92
View File
@@ -0,0 +1,92 @@
import type {
ActivityEntry,
Attachment,
BoardGroup,
CalendarEvent,
FocusSession,
Invite,
Label,
MailAddress,
Mailbox,
MailMessage,
Member,
Note,
OutgoingMail,
Task as BaseTask,
Workspace
} from "@productier/api-client";
// Extend Task with recurrence fields
export interface Task extends BaseTask {
recurrenceRule?: string;
recurrenceEnd?: string;
archived?: boolean;
}
export type {
ActivityEntry,
Attachment,
BoardGroup,
CalendarEvent,
FocusSession,
Invite,
Label,
MailAddress,
Mailbox,
MailMessage,
Member,
Note,
OutgoingMail,
Workspace
};
export type ThemeMode = "light" | "dark";
export type WorkspaceRole = Workspace["role"];
export type TaskStatus = Task["status"];
export type FocusMode = FocusSession["mode"];
export type QueueStatus = "queued" | "synced" | "conflict";
export type QueueEntityType = "board_group" | "label" | "task" | "event" | "note" | "focus_session";
export type QueueAction = "create" | "update";
export interface SessionUser {
id: string;
name: string;
email: string;
}
export interface OfflineQueueItem {
id: string;
entityType: QueueEntityType;
action: QueueAction;
entityId?: string;
description: string;
createdAt: string;
status: QueueStatus;
payload?: Record<string, unknown>;
}
export interface AppState {
theme: ThemeMode;
session: SessionUser | null;
workspaces: Workspace[];
members: Member[];
invites: Invite[];
boardGroups: BoardGroup[];
labels: Label[];
tasks: Task[];
events: CalendarEvent[];
notes: Note[];
focusSessions: FocusSession[];
activities: ActivityEntry[];
offlineQueue: OfflineQueueItem[];
// CRM
contacts: import("./types-crm").Contact[];
companies: import("./types-crm").Company[];
inboxItems: import("./types-crm").InboxItem[];
timeEntries: import("./types-crm").TimeEntry[];
savedViews: import("./types-crm").SavedView[];
// Integrations
integrations: import("./types-integrations").Integration[];
webhooks: import("./types-integrations").Webhook[];
notifications: import("./types-integrations").Notification[];
}
+115
View File
@@ -0,0 +1,115 @@
import { createSignal } from "solid-js";
interface UndoAction {
type: string;
description: string;
timestamp: number;
undo: () => void;
}
const MAX_UNDO_HISTORY = 50;
const [undoStack, setUndoStack] = createSignal<UndoAction[]>([]);
const [redoStack, setRedoStack] = createSignal<UndoAction[]>([]);
export function useUndoRedo() {
const canUndo = () => undoStack().length > 0;
const canRedo = () => redoStack().length > 0;
const pushUndo = (action: Omit<UndoAction, "timestamp">) => {
const undoAction: UndoAction = {
...action,
timestamp: Date.now(),
};
setUndoStack((stack) => {
const newStack = [undoAction, ...stack].slice(0, MAX_UNDO_HISTORY);
return newStack;
});
// Clear redo stack when new action is performed
setRedoStack([]);
};
const undo = () => {
const stack = undoStack();
if (stack.length === 0) return;
const [action, ...rest] = stack;
setUndoStack(rest);
// Execute undo
action.undo();
// Push to redo stack
setRedoStack((stack) => [action, ...stack]);
};
const redo = () => {
const stack = redoStack();
if (stack.length === 0) return;
const [action, ...rest] = stack;
setRedoStack(rest);
// Re-execute the original action (stored in undo function)
// This is a simplified version - in a full implementation,
// you'd store both undo and redo functions
setUndoStack((stack) => [action, ...stack]);
};
const clearHistory = () => {
setUndoStack([]);
setRedoStack([]);
};
const getLastUndoDescription = () => {
const stack = undoStack();
return stack.length > 0 ? stack[0].description : null;
};
const getLastRedoDescription = () => {
const stack = redoStack();
return stack.length > 0 ? stack[0].description : null;
};
return {
canUndo,
canRedo,
pushUndo,
undo,
redo,
clearHistory,
getLastUndoDescription,
getLastRedoDescription,
undoStack,
redoStack,
};
}
// Global keyboard shortcuts for undo/redo
export function setupUndoRedoShortcuts(undo: () => void, redo: () => void) {
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Ctrl+Z (undo) or Cmd+Z on Mac
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
// Check for Ctrl+Shift+Z (redo) or Cmd+Shift+Z on Mac
if ((e.ctrlKey || e.metaKey) && e.key === "z" && e.shiftKey) {
e.preventDefault();
redo();
return;
}
// Check for Ctrl+Y (redo) on Windows
if ((e.ctrlKey || e.metaKey) && e.key === "y") {
e.preventDefault();
redo();
return;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
+72
View File
@@ -0,0 +1,72 @@
import {
addDays,
endOfMonth,
endOfWeek,
format,
isSameDay,
isToday,
parseISO,
startOfMonth,
startOfWeek
} from "date-fns";
export const colorOptions = ["slate", "rose", "amber", "emerald", "blue"] as const;
const apiBaseUrl = (import.meta.env.VITE_API_URL || "http://localhost:48080").replace(/\/+$/, "");
export const colorMap: Record<string, { bg: string; text: string; line: string }> = {
slate: { bg: "rgba(103, 112, 130, 0.12)", text: "#5d6777", line: "rgba(103, 112, 130, 0.2)" },
rose: { bg: "rgba(229, 125, 125, 0.14)", text: "#c95c61", line: "rgba(229, 125, 125, 0.22)" },
amber: { bg: "rgba(214, 161, 85, 0.16)", text: "#a86e1f", line: "rgba(214, 161, 85, 0.24)" },
emerald: { bg: "rgba(111, 143, 116, 0.16)", text: "#4a7550", line: "rgba(111, 143, 116, 0.24)" },
blue: { bg: "rgba(111, 150, 211, 0.16)", text: "#4d6ea8", line: "rgba(111, 150, 211, 0.22)" }
};
export function getColorStyle(name: string) {
return colorMap[name] ?? colorMap.slate;
}
export function formatStamp(value?: string) {
if (!value) {
return "No date";
}
return format(parseISO(value), "MMM d, HH:mm");
}
export function formatDayLabel(value: Date) {
return format(value, "EEE d");
}
export function formatPrettyDate(value?: string) {
if (!value) {
return "Unscheduled";
}
const date = parseISO(value);
return isToday(date) ? `Today, ${format(date, "HH:mm")}` : format(date, "MMM d, HH:mm");
}
export function daysForMonth(base: Date) {
const start = startOfWeek(startOfMonth(base), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(base), { weekStartsOn: 1 });
const days: Date[] = [];
for (let cursor = start; cursor <= end; cursor = addDays(cursor, 1)) {
days.push(cursor);
}
return days;
}
export function sameDay(value: string | undefined, day: Date) {
return value ? isSameDay(parseISO(value), day) : false;
}
export function resolveAttachmentHref(value?: string) {
if (!value) {
return "#";
}
if (value.startsWith("data:") || value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
if (value.startsWith("/")) {
return `${apiBaseUrl}${value}`;
}
return `${apiBaseUrl}/${value}`;
}
+16
View File
@@ -0,0 +1,16 @@
import { A } from "@solidjs/router";
export default function NotFound() {
return (
<main class="flex min-h-screen items-center justify-center px-4">
<div class="app-surface w-full max-w-lg rounded-[2rem] p-8 text-center">
<p class="section-title">404</p>
<h1 class="mt-3 text-3xl font-extrabold tracking-[-0.05em]">That page doesnt exist.</h1>
<p class="mt-4 text-soft">Head back to the workspace shell and continue from there.</p>
<A href="/" class="button-primary mt-6 inline-flex px-5 py-3 text-sm">
Return home
</A>
</div>
</main>
);
}
@@ -0,0 +1,57 @@
import { A, useNavigate, useParams } from "@solidjs/router";
import { createSignal, onMount, Show } from "solid-js";
import { authClient } from "~/lib/auth-client";
import { useApp } from "~/lib/app-context";
import type { Invite } from "~/lib/types";
export default function AcceptInvitePage() {
const params = useParams();
const navigate = useNavigate();
const app = useApp();
const session = authClient.useSession();
const [accepted, setAccepted] = createSignal(false);
const [invite, setInvite] = createSignal<Invite | null>(null);
onMount(() => {
const token = params.token;
if (!token) return;
void app.getInviteByToken(token).then(entry => {
setInvite(entry);
if (entry?.status === "accepted") {
setAccepted(true);
}
});
});
const accept = async () => {
const token = params.token;
if (!token) return;
const success = await app.acceptInvite(token);
setAccepted(success);
if (success) {
navigate(`/app/${app.primaryWorkspace()?.slug ?? "personal"}/settings`);
}
};
return (
<main class="flex min-h-screen items-center justify-center px-4 py-6">
<div class="app-surface w-full max-w-xl rounded-[2rem] p-8">
<p class="section-title">Workspace Invitation</p>
<h1 class="mt-3 text-3xl font-extrabold tracking-[-0.05em]">Join Personal HQ</h1>
<Show when={invite()} fallback={<p class="mt-4 text-soft">This invite link is missing or expired.</p>}>
<p class="mt-4 text-sm leading-7 text-soft">
{invite()!.email} was invited as a {invite()!.role}. Accept the link after signing in to add the member to the workspace.
</p>
<Show when={session().data} fallback={<A href="/login" class="button-primary mt-6 inline-flex px-5 py-3 text-sm">Sign in first</A>}>
<button class="button-primary mt-6 inline-flex px-5 py-3 text-sm" onClick={() => void accept()}>
{accepted() ? "Invitation accepted" : "Accept invitation"}
</button>
</Show>
</Show>
</div>
</main>
);
}
@@ -0,0 +1,451 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { ArrowLeft, ArrowRight, Settings2, Trash2 } from "lucide-solid";
import Modal from "~/components/modal";
import { useApp } from "~/lib/app-context";
import { colorOptions, formatPrettyDate, getColorStyle, resolveAttachmentHref } from "~/lib/utils";
export default function BoardRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const groups = createMemo(() =>
[...app.state.boardGroups]
.filter(group => group.workspaceSlug === workspaceSlug())
.sort((left, right) => left.order - right.order),
);
const labels = createMemo(() => app.state.labels.filter(label => label.workspaceSlug === workspaceSlug()));
const [selectedTaskId, setSelectedTaskId] = createSignal<string | null>(null);
const [newTaskTitle, setNewTaskTitle] = createSignal("");
const [newGroup, setNewGroup] = createSignal("");
const [newGroupColor, setNewGroupColor] = createSignal("slate");
const [newComment, setNewComment] = createSignal("");
const [editingGroupId, setEditingGroupId] = createSignal<string | null>(null);
const [editingGroupName, setEditingGroupName] = createSignal("");
const [editingGroupColor, setEditingGroupColor] = createSignal("slate");
let draggedTaskId: string | null = null;
const selectedTask = createMemo(() =>
app.state.tasks.find(task => task.id === selectedTaskId()),
);
const tasksForGroup = (groupId: string) =>
app.state.tasks.filter(task => task.workspaceSlug === workspaceSlug() && task.boardGroupId === groupId);
const createInboxTask = async () => {
if (!newTaskTitle().trim()) {
return;
}
const task = await app.createTask({
workspaceSlug: workspaceSlug(),
boardGroupId: "group-inbox",
title: newTaskTitle(),
color: "slate"
});
setSelectedTaskId(task.id);
setNewTaskTitle("");
};
const addGroup = () => {
if (!newGroup().trim()) {
return;
}
app.addBoardGroup(workspaceSlug(), newGroup(), newGroupColor());
setNewGroup("");
setNewGroupColor("slate");
};
const openGroupSettings = (groupId: string) => {
const group = groups().find(entry => entry.id === groupId);
if (!group) {
return;
}
setEditingGroupId(group.id);
setEditingGroupName(group.name);
setEditingGroupColor(group.color);
};
const saveGroupSettings = () => {
const groupId = editingGroupId();
if (!groupId || !editingGroupName().trim()) {
return;
}
app.updateBoardGroup(groupId, {
name: editingGroupName().trim(),
color: editingGroupColor()
});
setEditingGroupId(null);
};
return (
<div class="flex h-full flex-col">
{/* Page header */}
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-xl font-semibold">Board</h1>
<p class="mt-1 text-sm text-[var(--text-muted)]">Flexible custom groups</p>
</div>
<div class="flex gap-2">
<input
class="input-base w-64"
placeholder="Quick capture..."
value={newTaskTitle()}
onInput={event => setNewTaskTitle(event.currentTarget.value)}
onKeyDown={event => event.key === "Enter" && void createInboxTask()}
/>
<button class="button-primary px-4 py-2 text-sm" onClick={() => void createInboxTask()}>
Add task
</button>
</div>
</div>
{/* Board columns */}
<div class="flex-1 overflow-hidden">
<div class="scrollbar-thin flex h-full gap-4 overflow-x-auto pb-4">
<For each={groups()}>
{group => (
<div
class="w-72 shrink-0 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
onDragOver={event => event.preventDefault()}
onDrop={() => draggedTaskId && app.moveTask(draggedTaskId, group.id)}
>
{/* Column header */}
<div class="border-b border-[var(--border)] px-3 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="h-3 w-3 rounded-full"
style={{ background: getColorStyle(group.color).bg }}
/>
<span class="text-sm font-medium">{group.name}</span>
<span class="text-xs text-[var(--text-muted)]">
{tasksForGroup(group.id).length}
</span>
</div>
<div class="flex items-center gap-1">
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-subtle)] hover:text-[var(--text)] disabled:opacity-30"
aria-label={`Move ${group.name} left`}
onClick={() => app.reorderBoardGroup(group.id, -1)}
disabled={groups().findIndex(entry => entry.id === group.id) === 0}
>
<ArrowLeft size={14} />
</button>
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-subtle)] hover:text-[var(--text)] disabled:opacity-30"
aria-label={`Move ${group.name} right`}
onClick={() => app.reorderBoardGroup(group.id, 1)}
disabled={groups().findIndex(entry => entry.id === group.id) === groups().length - 1}
>
<ArrowRight size={14} />
</button>
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-subtle)] hover:text-[var(--text)]"
aria-label={`Edit ${group.name}`}
onClick={() => openGroupSettings(group.id)}
>
<Settings2 size={14} />
</button>
</div>
</div>
</div>
{/* Tasks */}
<div class="max-h-[calc(100vh-220px)] space-y-2 overflow-y-auto p-2">
<For each={tasksForGroup(group.id)}>
{task => {
const color = getColorStyle(task.color);
return (
<button
draggable
onDragStart={() => {
draggedTaskId = task.id;
}}
onClick={() => setSelectedTaskId(task.id)}
class="w-full rounded-md border border-[var(--border)] bg-[var(--bg-subtle)] p-3 text-left transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--bg-muted)]"
>
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-medium">{task.title}</p>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium"
style={{ background: color.bg, color: color.text }}
>
{task.status.replace("_", " ").slice(0, 3)}
</span>
</div>
<Show when={task.description}>
<p class="mt-1 line-clamp-2 text-xs text-[var(--text-muted)]">
{task.description}
</p>
</Show>
<Show when={task.labelIds.length > 0}>
<div class="mt-2 flex flex-wrap gap-1">
<For each={task.labelIds.slice(0, 3).map(labelId => labels().find(label => label.id === labelId)).filter(Boolean)}>
{label => (
<span
class="rounded px-1.5 py-0.5 text-xs font-medium"
style={{ background: getColorStyle(label!.color).bg, color: getColorStyle(label!.color).text }}
>
{label!.name}
</span>
)}
</For>
</div>
</Show>
<Show when={task.dueAt}>
<p class="mt-2 text-xs text-[var(--text-subtle)]">
{formatPrettyDate(task.dueAt)}
</p>
</Show>
</button>
);
}}
</For>
</div>
</div>
)}
</For>
{/* Add group column */}
<div class="w-72 shrink-0 rounded-lg border border-dashed border-[var(--border)] bg-[var(--bg-subtle)] p-3">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">Add group</p>
<input
class="input-base mb-2 text-sm"
placeholder="Group name"
value={newGroup()}
onInput={event => setNewGroup(event.currentTarget.value)}
/>
<select
class="input-base mb-2 text-sm"
value={newGroupColor()}
onInput={event => setNewGroupColor(event.currentTarget.value)}
>
<For each={colorOptions}>{option => <option value={option}>{option}</option>}</For>
</select>
<button
class="button-secondary w-full px-3 py-1.5 text-sm"
onClick={() => addGroup()}
>
Add group
</button>
</div>
</div>
</div>
{/* Labels footer */}
<div class="mt-4 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">Labels</span>
<div class="flex flex-wrap gap-2">
<For each={labels()}>
{label => (
<span
class="rounded px-2 py-0.5 text-xs font-medium"
style={{ background: getColorStyle(label.color).bg, color: getColorStyle(label.color).text }}
>
{label.name}
</span>
)}
</For>
</div>
</div>
</div>
{/* Edit group modal */}
<Modal open={Boolean(editingGroupId())} title="Edit group" onClose={() => setEditingGroupId(null)}>
<div class="space-y-4">
<div>
<label class="mb-1.5 block text-sm font-medium">Name</label>
<input
class="input-base"
value={editingGroupName()}
onInput={event => setEditingGroupName(event.currentTarget.value)}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium">Color</label>
<select
class="input-base"
value={editingGroupColor()}
onInput={event => setEditingGroupColor(event.currentTarget.value)}
>
<For each={colorOptions}>{option => <option value={option}>{option}</option>}</For>
</select>
</div>
<button class="button-primary w-full px-4 py-2 text-sm" onClick={saveGroupSettings}>
Save changes
</button>
</div>
</Modal>
{/* Task detail modal */}
<Modal open={Boolean(selectedTask())} title={selectedTask()?.title ?? "Task"} onClose={() => setSelectedTaskId(null)}>
<Show when={selectedTask()}>
{task => (
<div class="space-y-4">
<div>
<label class="mb-1.5 block text-sm font-medium">Title</label>
<input
class="input-base"
value={task().title}
onInput={event => app.updateTask(task().id, current => {
current.title = event.currentTarget.value;
})}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium">Description</label>
<textarea
class="input-base min-h-24 resize-none"
value={task().description}
onInput={event => app.updateTask(task().id, current => {
current.description = event.currentTarget.value;
})}
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium">Due date</label>
<input
class="input-base"
type="datetime-local"
value={task().dueAt?.slice(0, 16) ?? ""}
onInput={event => app.updateTask(task().id, current => {
current.dueAt = event.currentTarget.value ? new Date(event.currentTarget.value).toISOString() : undefined;
})}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium">Color</label>
<select
class="input-base"
value={task().color}
onInput={event => app.updateTask(task().id, current => {
current.color = event.currentTarget.value;
})}
>
<For each={colorOptions}>
{option => <option value={option}>{option}</option>}
</For>
</select>
</div>
</div>
{/* Labels */}
<div>
<p class="mb-2 text-sm font-medium">Labels</p>
<div class="flex flex-wrap gap-2">
<For each={labels()}>
{label => {
const active = () => task().labelIds.includes(label.id);
return (
<button
class="rounded px-2 py-1 text-xs font-medium transition-colors"
classList={{
"bg-[var(--bg-muted)]": !active()
}}
style={{
background: active() ? getColorStyle(label.color).bg : undefined,
color: active() ? getColorStyle(label.color).text : "var(--text-muted)"
}}
onClick={() =>
app.updateTask(task().id, current => {
current.labelIds = active()
? current.labelIds.filter(labelId => labelId !== label.id)
: [...current.labelIds, label.id];
})
}
>
{label.name}
</button>
);
}}
</For>
</div>
</div>
{/* Attachments */}
<div>
<label class="mb-1.5 block text-sm font-medium">Attachments</label>
<input
class="input-base"
type="file"
multiple
onChange={event => app.attachFilesToTask(task().id, event.currentTarget.files)}
/>
<Show when={task().attachments.length > 0}>
<div class="mt-2 space-y-1">
<For each={task().attachments}>
{attachment => (
<div class="flex items-center justify-between gap-2 rounded-md bg-[var(--bg-subtle)] px-3 py-2 text-sm">
<a
class="min-w-0 flex-1 truncate"
href={resolveAttachmentHref(attachment.dataUrl)}
download={attachment.name}
>
{attachment.name}
</a>
<span class="text-xs text-[var(--text-muted)]">
{Math.round(attachment.size / 1024)} KB
</span>
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-muted)] hover:text-[var(--error)]"
aria-label={`Remove ${attachment.name}`}
onClick={() => void app.removeTaskAttachment(task().id, attachment.id)}
>
<Trash2 size={14} />
</button>
</div>
)}
</For>
</div>
</Show>
</div>
{/* Comments */}
<div>
<p class="mb-2 text-sm font-medium">Comments</p>
<div class="mb-2 space-y-2">
<For each={task().comments}>
{comment => (
<div class="rounded-md bg-[var(--bg-subtle)] px-3 py-2">
<p class="text-xs font-medium">{comment.author}</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">{comment.content}</p>
</div>
)}
</For>
</div>
<div class="flex gap-2">
<input
class="input-base flex-1"
placeholder="Add a comment..."
value={newComment()}
onInput={event => setNewComment(event.currentTarget.value)}
onKeyDown={event => {
if (event.key === "Enter" && newComment().trim()) {
app.createTaskComment(task().id, newComment());
setNewComment("");
}
}}
/>
<button
class="button-secondary px-3 py-2 text-sm"
onClick={() => {
if (newComment().trim()) {
app.createTaskComment(task().id, newComment());
setNewComment("");
}
}}
>
Send
</button>
</div>
</div>
</div>
)}
</Show>
</Modal>
</div>
);
}
@@ -0,0 +1,423 @@
import {
addDays,
addMonths,
addWeeks,
format,
isSameMonth,
startOfDay,
startOfWeek,
subDays,
subMonths,
subWeeks
} from "date-fns";
import { ChevronLeft, ChevronRight, Plus } from "lucide-solid";
import { createMemo, createSignal, For, Show } from "solid-js";
import Modal from "~/components/modal";
import { useApp } from "~/lib/app-context";
import { colorOptions, daysForMonth, formatDayLabel, formatPrettyDate, getColorStyle, sameDay } from "~/lib/utils";
type CalendarView = "month" | "week" | "day";
export default function CalendarRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [view, setView] = createSignal<CalendarView>("month");
const [cursorDate, setCursorDate] = createSignal(startOfDay(new Date()));
const [selectedDate, setSelectedDate] = createSignal(startOfDay(new Date()));
const [showQuickAdd, setShowQuickAdd] = createSignal(false);
const [moreDate, setMoreDate] = createSignal<Date | null>(null);
const [quickType, setQuickType] = createSignal<"task" | "event">("task");
const [quickColor, setQuickColor] = createSignal("blue");
const [title, setTitle] = createSignal("");
const [description, setDescription] = createSignal("");
const scheduledTasks = createMemo(() =>
app.state.tasks.filter(task => task.workspaceSlug === workspaceSlug() && task.scheduledStart),
);
const events = createMemo(() => app.state.events.filter(event => event.workspaceSlug === workspaceSlug()));
const dayItems = (day: Date) => {
const tasks = scheduledTasks().filter(task => sameDay(task.scheduledStart, day)).map(task => ({
kind: "task" as const,
id: task.id,
title: task.title,
startsAt: task.scheduledStart!,
color: task.color
}));
const calendarEvents = events().filter(event => sameDay(event.startsAt, day)).map(event => ({
kind: "event" as const,
id: event.id,
title: event.title,
startsAt: event.startsAt,
color: event.color
}));
return [...tasks, ...calendarEvents].sort((left, right) => left.startsAt.localeCompare(right.startsAt));
};
const visibleDays = createMemo(() => {
if (view() === "month") {
return daysForMonth(cursorDate());
}
if (view() === "week") {
const weekStart = startOfWeek(cursorDate(), { weekStartsOn: 1 });
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
return [startOfDay(selectedDate())];
});
const rangeLabel = createMemo(() => {
if (view() === "month") {
return format(cursorDate(), "MMMM yyyy");
}
if (view() === "week") {
const days = visibleDays();
const start = days[0];
const end = days[days.length - 1];
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
}
return format(selectedDate(), "EEEE, MMM d, yyyy");
});
const changeRange = (direction: "next" | "prev") => {
if (view() === "month") {
setCursorDate(direction === "next" ? addMonths(cursorDate(), 1) : subMonths(cursorDate(), 1));
return;
}
if (view() === "week") {
setCursorDate(direction === "next" ? addWeeks(cursorDate(), 1) : subWeeks(cursorDate(), 1));
return;
}
const nextDay = direction === "next" ? addDays(selectedDate(), 1) : subDays(selectedDate(), 1);
setSelectedDate(startOfDay(nextDay));
setCursorDate(startOfDay(nextDay));
};
const submitQuickAdd = () => {
if (!title().trim()) {
return;
}
const day = selectedDate();
if (quickType() === "task") {
const start = new Date(day);
start.setHours(10, 0, 0, 0);
const end = new Date(day);
end.setHours(11, 0, 0, 0);
app.createTask({
workspaceSlug: workspaceSlug(),
boardGroupId: "group-inbox",
title: title(),
description: description(),
color: quickColor(),
scheduledStart: start.toISOString(),
scheduledEnd: end.toISOString()
});
} else {
const start = new Date(day);
start.setHours(13, 0, 0, 0);
const end = new Date(day);
end.setHours(14, 0, 0, 0);
app.createEvent({
workspaceSlug: workspaceSlug(),
title: title(),
description: description(),
startsAt: start.toISOString(),
endsAt: end.toISOString(),
color: quickColor()
});
}
setTitle("");
setDescription("");
setShowQuickAdd(false);
};
const openQuickAddForDay = (day: Date) => {
setSelectedDate(startOfDay(day));
setShowQuickAdd(true);
};
const handleDrop = (day: Date, payload: string) => {
try {
const parsed = JSON.parse(payload) as { kind: "task" | "event"; id: string };
if (parsed.kind === "task") {
app.scheduleTaskOnDay(parsed.id, new Date(day));
} else {
app.moveEventToDay(parsed.id, new Date(day));
}
} catch {
return;
}
};
return (
<div class="space-y-6">
{/* Page header */}
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-xl font-semibold">Calendar</h1>
<p class="mt-1 text-sm text-[var(--text-muted)]">Time-block the work</p>
</div>
<button
class="button-primary inline-flex items-center gap-2 px-4 py-2 text-sm"
onClick={() => setShowQuickAdd(true)}
>
<Plus size={16} />
Quick add
</button>
</div>
{/* Calendar controls */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)] p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* View switcher */}
<div class="flex rounded-lg bg-[var(--bg-subtle)] p-1">
<For each={["month", "week", "day"] as CalendarView[]}>
{item => (
<button
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
classList={{
"bg-[var(--surface)] text-[var(--text)] shadow-sm": view() === item,
"text-[var(--text-muted)] hover:text-[var(--text)]": view() !== item
}}
onClick={() => {
setView(item);
if (item === "day") {
setSelectedDate(startOfDay(cursorDate()));
}
}}
>
{item}
</button>
)}
</For>
</div>
{/* Range navigation */}
<div class="flex items-center gap-3">
<button
class="flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-subtle)] hover:text-[var(--text)]"
onClick={() => changeRange("prev")}
aria-label="Previous period"
>
<ChevronLeft size={18} />
</button>
<span class="min-w-[140px] text-center text-sm font-medium">{rangeLabel()}</span>
<button
class="flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-subtle)] hover:text-[var(--text)]"
onClick={() => changeRange("next")}
aria-label="Next period"
>
<ChevronRight size={18} />
</button>
</div>
</div>
</div>
{/* Calendar grid */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
{/* Day headers */}
<Show when={view() !== "day"}>
<div class="grid grid-cols-7 border-b border-[var(--border)]">
<For each={["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]}>
{day => (
<div class="px-2 py-2 text-center text-xs font-medium text-[var(--text-muted)]">
{day}
</div>
)}
</For>
</div>
</Show>
{/* Days grid */}
<div class={`grid ${view() === "day" ? "grid-cols-1" : "grid-cols-7"}`}>
<For each={visibleDays()}>
{day => {
const items = () => dayItems(day);
const isSelected = () => format(day, "yyyy-MM-dd") === format(selectedDate(), "yyyy-MM-dd");
const isCurrentMonth = () => view() === "month" ? isSameMonth(day, cursorDate()) : true;
return (
<div
class={`min-h-[120px] border-r border-b border-[var(--border)] p-2 transition-colors last:border-r-0 ${
view() === "day" ? "min-h-[400px]" : ""
} ${isSelected() ? "bg-[var(--accent-subtle)]" : "hover:bg-[var(--bg-subtle)]"}`}
onDragOver={event => event.preventDefault()}
onDrop={event => handleDrop(day, event.dataTransfer?.getData("text/plain") ?? "")}
>
{/* Day header */}
<div class="mb-2 flex items-center justify-between">
<button
class="flex items-center gap-1 text-left"
onClick={() => setSelectedDate(day)}
>
<span class={`text-sm font-medium ${isCurrentMonth() ? "" : "text-[var(--text-muted)]"}`}>
{formatDayLabel(day)}
</span>
<Show when={items().length > 0}>
<span class="rounded-full bg-[var(--accent-subtle)] px-1.5 py-0.5 text-xs font-medium text-[var(--accent)]">
{items().length}
</span>
</Show>
</button>
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] opacity-0 transition-opacity hover:bg-[var(--bg-muted)] hover:text-[var(--text)] group-hover:opacity-100"
onClick={() => openQuickAddForDay(day)}
aria-label="Add item"
>
<Plus size={14} />
</button>
</div>
{/* Items */}
<div class="space-y-1">
<For each={items().slice(0, view() === "day" ? 20 : 3)}>
{item => {
const color = getColorStyle(item.color);
return (
<div
draggable
onDragStart={event =>
event.dataTransfer?.setData("text/plain", JSON.stringify({ kind: item.kind, id: item.id }))
}
class="truncate rounded px-2 py-1 text-xs font-medium cursor-grab"
style={{ background: color.bg, color: color.text }}
>
{item.title}
</div>
);
}}
</For>
<Show when={items().length > (view() === "day" ? 20 : 3)}>
<button
class="w-full rounded px-2 py-1 text-left text-xs text-[var(--text-muted)] hover:bg-[var(--bg-subtle)]"
onClick={() => setMoreDate(day)}
>
+{items().length - (view() === "day" ? 20 : 3)} more
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
{/* Selected day detail */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">{format(selectedDate(), "EEEE, MMMM d, yyyy")}</h2>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={dayItems(selectedDate()).length === 0}>
<div class="px-4 py-6 text-center text-sm text-[var(--text-muted)]">
No items scheduled
</div>
</Show>
<For each={dayItems(selectedDate())}>
{item => {
const color = getColorStyle(item.color);
return (
<div class="flex items-center gap-3 px-4 py-3">
<span
class="shrink-0 rounded px-2 py-0.5 text-xs font-medium"
style={{ background: color.bg, color: color.text }}
>
{item.kind}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{item.title}</p>
<p class="text-xs text-[var(--text-muted)]">{formatPrettyDate(item.startsAt)}</p>
</div>
</div>
);
}}
</For>
</div>
</div>
{/* Quick add modal */}
<Modal open={showQuickAdd()} title="Quick add" onClose={() => setShowQuickAdd(false)}>
<div class="space-y-4">
<p class="text-sm text-[var(--text-muted)]">{format(selectedDate(), "EEEE, MMM d, yyyy")}</p>
{/* Type toggle */}
<div class="flex rounded-lg bg-[var(--bg-subtle)] p-1">
<For each={["task", "event"] as const}>
{item => (
<button
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors"
classList={{
"bg-[var(--surface)] text-[var(--text)] shadow-sm": quickType() === item,
"text-[var(--text-muted)] hover:text-[var(--text)]": quickType() !== item
}}
onClick={() => {
setQuickType(item);
setQuickColor(item === "task" ? "blue" : "amber");
}}
>
{item}
</button>
)}
</For>
</div>
<select
class="input-base"
value={quickColor()}
onInput={event => setQuickColor(event.currentTarget.value)}
>
<For each={colorOptions}>{option => <option value={option}>{option}</option>}</For>
</select>
<input
class="input-base"
placeholder="Title"
value={title()}
onInput={event => setTitle(event.currentTarget.value)}
/>
<textarea
class="input-base min-h-24 resize-none"
placeholder="Description (optional)"
value={description()}
onInput={event => setDescription(event.currentTarget.value)}
/>
<button class="button-primary w-full px-4 py-2 text-sm" onClick={submitQuickAdd}>
Save item
</button>
</div>
</Modal>
{/* Day preview modal */}
<Modal open={Boolean(moreDate())} title={moreDate() ? format(moreDate()!, "EEEE, MMM d") : "Day preview"} onClose={() => setMoreDate(null)}>
<div class="divide-y divide-[var(--border)]">
<For each={moreDate() ? dayItems(moreDate()!) : []}>
{item => {
const color = getColorStyle(item.color);
return (
<div class="flex items-center gap-3 py-3">
<span
class="shrink-0 rounded px-2 py-0.5 text-xs font-medium"
style={{ background: color.bg, color: color.text }}
>
{item.kind}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{item.title}</p>
<p class="text-xs text-[var(--text-muted)]">{formatPrettyDate(item.startsAt)}</p>
</div>
</div>
);
}}
</For>
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,388 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { Building2, Globe, Plus, Search, Trash2 } from "lucide-solid";
import Modal from "~/components/modal";
import { useApp } from "~/lib/app-context";
import {
apiListCompanies,
apiCreateCompany,
apiUpdateCompany,
apiDeleteCompany
} from "~/lib/api-crm";
import type { Company } from "~/lib/types-crm";
const industryOptions = [
"Technology",
"Healthcare",
"Finance",
"Education",
"Retail",
"Manufacturing",
"Consulting",
"Media",
"Other"
];
const sizeOptions = ["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"];
export default function CompaniesRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [companies, setCompanies] = createSignal<Company[]>([]);
const [loading, setLoading] = createSignal(true);
const [search, setSearch] = createSignal("");
const [showAdd, setShowAdd] = createSignal(false);
const [selectedId, setSelectedId] = createSignal<string | null>(null);
// Form state
const [name, setName] = createSignal("");
const [domain, setDomain] = createSignal("");
const [website, setWebsite] = createSignal("");
const [industry, setIndustry] = createSignal("");
const [size, setSize] = createSignal("");
const [notes, setNotes] = createSignal("");
const loadCompanies = async () => {
setLoading(true);
try {
const list = await apiListCompanies(workspaceSlug());
setCompanies(list);
} catch (e) {
console.error("Failed to load companies", e);
}
setLoading(false);
};
if (companies().length === 0) {
loadCompanies();
}
const filteredCompanies = createMemo(() => {
const q = search().toLowerCase();
if (!q) return companies();
return companies().filter(c =>
c.name.toLowerCase().includes(q) ||
c.domain.toLowerCase().includes(q) ||
c.industry.toLowerCase().includes(q)
);
});
const resetForm = () => {
setName("");
setDomain("");
setWebsite("");
setIndustry("");
setSize("");
setNotes("");
};
const handleAdd = async () => {
if (!name().trim()) return;
const company = await apiCreateCompany({
workspaceSlug: workspaceSlug(),
name: name(),
domain: domain(),
website: website(),
industry: industry(),
size: size(),
notes: notes()
});
setCompanies([...companies(), company]);
setShowAdd(false);
resetForm();
};
const handleUpdate = async () => {
const id = selectedId();
if (!id) return;
const updated = await apiUpdateCompany(id, {
name: name(),
domain: domain(),
website: website(),
industry: industry(),
size: size(),
notes: notes()
});
setCompanies(companies().map(c => c.id === id ? updated : c));
setSelectedId(null);
resetForm();
};
const handleDelete = async (id: string) => {
await apiDeleteCompany(id);
setCompanies(companies().filter(c => c.id !== id));
if (selectedId() === id) setSelectedId(null);
};
const openEdit = (company: Company) => {
setSelectedId(company.id);
setName(company.name);
setDomain(company.domain);
setWebsite(company.website);
setIndustry(company.industry);
setSize(company.size);
setNotes(company.notes);
};
return (
<div class="flex h-full gap-6">
{/* Main list */}
<div class="flex-1 flex flex-col">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold">Companies</h1>
<button
onClick={() => { resetForm(); setShowAdd(true); }}
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
<Plus class="w-4 h-4" />
Add Company
</button>
</div>
{/* Search */}
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
<input
type="text"
placeholder="Search companies..."
value={search()}
onInput={e => setSearch(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
{/* Company list */}
<Show when={!loading()} fallback={<div class="text-[var(--text-muted)]">Loading...</div>}>
<div class="flex-1 overflow-auto space-y-2">
<Show when={filteredCompanies().length === 0}>
<div class="text-center py-12 text-[var(--text-muted)]">
No companies yet. Add your first company to get started.
</div>
</Show>
<For each={filteredCompanies()}>
{company => (
<div
class="flex items-center gap-4 p-4 rounded-lg border border-[var(--border)] bg-[var(--surface)] hover:border-[var(--accent)] cursor-pointer transition-colors"
onClick={() => openEdit(company)}
>
<div class="w-10 h-10 rounded-lg bg-[var(--secondary)] flex items-center justify-center text-white">
<Building2 class="w-5 h-5" />
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{company.name}</div>
<div class="text-sm text-[var(--text-muted)] truncate">
{company.industry && <span>{company.industry}</span>}
{company.industry && company.size && <span> </span>}
{company.size && <span>{company.size} employees</span>}
</div>
</div>
{company.website && (
<a
href={company.website}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
class="text-[var(--text-muted)] hover:text-[var(--accent)]"
>
<Globe class="w-4 h-4" />
</a>
)}
</div>
)}
</For>
</div>
</Show>
</div>
{/* Add Modal */}
<Modal open={showAdd()} onClose={() => setShowAdd(false)} title="Add Company">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name *</label>
<input
type="text"
value={name()}
onInput={e => setName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
placeholder="Company name"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Domain</label>
<input
type="text"
value={domain()}
onInput={e => setDomain(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
placeholder="example.com"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Website</label>
<input
type="url"
value={website()}
onInput={e => setWebsite(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
placeholder="https://example.com"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Industry</label>
<select
value={industry()}
onChange={e => setIndustry(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
>
<option value="">Select industry</option>
<For each={industryOptions}>
{opt => <option value={opt}>{opt}</option>}
</For>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Company Size</label>
<select
value={size()}
onChange={e => setSize(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
>
<option value="">Select size</option>
<For each={sizeOptions}>
{opt => <option value={opt}>{opt}</option>}
</For>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Notes</label>
<textarea
value={notes()}
onInput={e => setNotes(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] min-h-[80px]"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowAdd(false)}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)]"
>
Cancel
</button>
<button
onClick={handleAdd}
class="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
Add Company
</button>
</div>
</div>
</Modal>
{/* Edit Modal */}
<Modal open={selectedId() !== null} onClose={() => { setSelectedId(null); resetForm(); }} title="Edit Company">
<Show when={companies().find(c => c.id === selectedId())}>
{company => (
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name *</label>
<input
type="text"
value={name()}
onInput={e => setName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Domain</label>
<input
type="text"
value={domain()}
onInput={e => setDomain(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Website</label>
<input
type="url"
value={website()}
onInput={e => setWebsite(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Industry</label>
<select
value={industry()}
onChange={e => setIndustry(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
>
<option value="">Select industry</option>
<For each={industryOptions}>
{opt => <option value={opt}>{opt}</option>}
</For>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Company Size</label>
<select
value={size()}
onChange={e => setSize(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
>
<option value="">Select size</option>
<For each={sizeOptions}>
{opt => <option value={opt}>{opt}</option>}
</For>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Notes</label>
<textarea
value={notes()}
onInput={e => setNotes(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] min-h-[80px]"
/>
</div>
<div class="flex justify-between pt-2">
<button
onClick={() => handleDelete(company.id)}
class="px-4 py-2 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
>
Delete
</button>
<div class="flex gap-2">
<button
onClick={() => { setSelectedId(null); resetForm(); }}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)]"
>
Cancel
</button>
<button
onClick={handleUpdate}
class="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
Save
</button>
</div>
</div>
</div>
)}
</Show>
</Modal>
</div>
);
}
@@ -0,0 +1,391 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { Building2, Mail, Phone, Plus, Search, Trash2, User } from "lucide-solid";
import Modal from "~/components/modal";
import { useApp } from "~/lib/app-context";
import {
apiListContacts,
apiCreateContact,
apiUpdateContact,
apiDeleteContact,
apiListCompanies
} from "~/lib/api-crm";
import type { Contact, Company } from "~/lib/types-crm";
import { formatPrettyDate } from "~/lib/utils";
export default function ContactsRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [contacts, setContacts] = createSignal<Contact[]>([]);
const [companies, setCompanies] = createSignal<Company[]>([]);
const [loading, setLoading] = createSignal(true);
const [search, setSearch] = createSignal("");
const [showAdd, setShowAdd] = createSignal(false);
const [selectedId, setSelectedId] = createSignal<string | null>(null);
// Form state
const [firstName, setFirstName] = createSignal("");
const [lastName, setLastName] = createSignal("");
const [email, setEmail] = createSignal("");
const [phone, setPhone] = createSignal("");
const [companyId, setCompanyId] = createSignal<string>("");
const [title, setTitle] = createSignal("");
const [notes, setNotes] = createSignal("");
const loadContacts = async () => {
setLoading(true);
try {
const [contactList, companyList] = await Promise.all([
apiListContacts(workspaceSlug()),
apiListCompanies(workspaceSlug())
]);
setContacts(contactList);
setCompanies(companyList);
} catch (e) {
console.error("Failed to load contacts", e);
}
setLoading(false);
};
// Load on mount
if (contacts().length === 0) {
loadContacts();
}
const filteredContacts = createMemo(() => {
const q = search().toLowerCase();
if (!q) return contacts();
return contacts().filter(c =>
c.firstName.toLowerCase().includes(q) ||
c.lastName.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
c.companyName?.toLowerCase().includes(q)
);
});
const selectedContact = createMemo(() =>
contacts().find(c => c.id === selectedId())
);
const resetForm = () => {
setFirstName("");
setLastName("");
setEmail("");
setPhone("");
setCompanyId("");
setTitle("");
setNotes("");
};
const handleAdd = async () => {
if (!firstName().trim() && !lastName().trim()) return;
const contact = await apiCreateContact({
workspaceSlug: workspaceSlug(),
firstName: firstName(),
lastName: lastName(),
email: email(),
phone: phone(),
companyId: companyId() || undefined,
title: title(),
notes: notes()
});
setContacts([...contacts(), contact]);
setShowAdd(false);
resetForm();
};
const handleUpdate = async () => {
const id = selectedId();
if (!id) return;
const updated = await apiUpdateContact(id, {
firstName: firstName(),
lastName: lastName(),
email: email(),
phone: phone(),
companyId: companyId() || undefined,
title: title(),
notes: notes()
});
setContacts(contacts().map(c => c.id === id ? updated : c));
setSelectedId(null);
resetForm();
};
const handleDelete = async (id: string) => {
await apiDeleteContact(id);
setContacts(contacts().filter(c => c.id !== id));
if (selectedId() === id) setSelectedId(null);
};
const openEdit = (contact: Contact) => {
setSelectedId(contact.id);
setFirstName(contact.firstName);
setLastName(contact.lastName);
setEmail(contact.email);
setPhone(contact.phone);
setCompanyId(contact.companyId || "");
setTitle(contact.title);
setNotes(contact.notes);
};
return (
<div class="flex h-full gap-6">
{/* Main list */}
<div class="flex-1 flex flex-col">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-semibold">Contacts</h1>
<button
onClick={() => { resetForm(); setShowAdd(true); }}
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
<Plus class="w-4 h-4" />
Add Contact
</button>
</div>
{/* Search */}
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
<input
type="text"
placeholder="Search contacts..."
value={search()}
onInput={e => setSearch(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
{/* Contact list */}
<Show when={!loading()} fallback={<div class="text-[var(--text-muted)]">Loading...</div>}>
<div class="flex-1 overflow-auto space-y-2">
<Show when={filteredContacts().length === 0}>
<div class="text-center py-12 text-[var(--text-muted)]">
No contacts yet. Add your first contact to get started.
</div>
</Show>
<For each={filteredContacts()}>
{contact => (
<div
class="flex items-center gap-4 p-4 rounded-lg border border-[var(--border)] bg-[var(--surface)] hover:border-[var(--accent)] cursor-pointer transition-colors"
onClick={() => openEdit(contact)}
>
<div class="w-10 h-10 rounded-full bg-[var(--accent)] flex items-center justify-center text-white font-medium">
{(contact.firstName[0] || "") + (contact.lastName[0] || "")}
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">
{contact.firstName} {contact.lastName}
</div>
<div class="text-sm text-[var(--text-muted)] truncate">
{contact.title && <span>{contact.title}</span>}
{contact.title && contact.companyName && <span> at </span>}
{contact.companyName && <span>{contact.companyName}</span>}
</div>
</div>
<div class="flex items-center gap-2 text-[var(--text-muted)]">
{contact.email && <Mail class="w-4 h-4" />}
{contact.phone && <Phone class="w-4 h-4" />}
</div>
</div>
)}
</For>
</div>
</Show>
</div>
{/* Add Modal */}
<Modal open={showAdd()} onClose={() => setShowAdd(false)} title="Add Contact">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
value={firstName()}
onInput={e => setFirstName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
value={lastName()}
onInput={e => setLastName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email()}
onInput={e => setEmail(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
value={phone()}
onInput={e => setPhone(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Company</label>
<select
value={companyId()}
onChange={e => setCompanyId(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
>
<option value="">None</option>
<For each={companies()}>
{company => <option value={company.id}>{company.name}</option>}
</For>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Title</label>
<input
type="text"
value={title()}
onInput={e => setTitle(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
placeholder="e.g. Product Manager"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Notes</label>
<textarea
value={notes()}
onInput={e => setNotes(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] min-h-[80px]"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowAdd(false)}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)]"
>
Cancel
</button>
<button
onClick={handleAdd}
class="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
Add Contact
</button>
</div>
</div>
</Modal>
{/* Edit Modal */}
<Modal open={selectedId() !== null} onClose={() => { setSelectedId(null); resetForm(); }} title="Edit Contact">
<Show when={selectedContact()}>
{contact => (
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">First Name</label>
<input
type="text"
value={firstName()}
onInput={e => setFirstName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Last Name</label>
<input
type="text"
value={lastName()}
onInput={e => setLastName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email()}
onInput={e => setEmail(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
value={phone()}
onInput={e => setPhone(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Company</label>
<select
value={companyId()}
onChange={e => setCompanyId(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
>
<option value="">None</option>
<For each={companies()}>
{company => <option value={company.id}>{company.name}</option>}
</For>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Title</label>
<input
type="text"
value={title()}
onInput={e => setTitle(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Notes</label>
<textarea
value={notes()}
onInput={e => setNotes(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] min-h-[80px]"
/>
</div>
<div class="flex justify-between pt-2">
<button
onClick={() => handleDelete(contact.id)}
class="px-4 py-2 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
>
Delete
</button>
<div class="flex gap-2">
<button
onClick={() => { setSelectedId(null); resetForm(); }}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)]"
>
Cancel
</button>
<button
onClick={handleUpdate}
class="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
Save
</button>
</div>
</div>
</div>
)}
</Show>
</Modal>
</div>
);
}
@@ -0,0 +1,164 @@
import { differenceInSeconds, parseISO } from "date-fns";
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { useApp } from "~/lib/app-context";
import { formatStamp } from "~/lib/utils";
const presets = [
{ mode: "focus" as const, label: "Focus", seconds: 1500 },
{ mode: "short_break" as const, label: "Short break", seconds: 300 },
{ mode: "long_break" as const, label: "Long break", seconds: 900 }
];
export default function FocusRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [now, setNow] = createSignal(Date.now());
const [selectedTaskId, setSelectedTaskId] = createSignal<string | undefined>(app.state.tasks[0]?.id);
const [selectedMode, setSelectedMode] = createSignal(presets[0]);
onMount(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
onCleanup(() => window.clearInterval(timer));
});
const active = createMemo(() => app.activeFocusSession());
const remaining = createMemo(() => {
const session = active();
if (!session) {
return selectedMode().seconds;
}
const startedAt = parseISO(session.startedAt).getTime();
const pausedBase = session.pausedTotalSeconds;
const pausedLive = session.pausedAt ? differenceInSeconds(new Date(now()), parseISO(session.pausedAt)) : 0;
const elapsed = differenceInSeconds(new Date(now()), new Date(startedAt)) - pausedBase - pausedLive;
return Math.max(session.durationSeconds - elapsed, 0);
});
const display = createMemo(() => {
const seconds = remaining();
const minutes = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const remainder = Math.floor(seconds % 60)
.toString()
.padStart(2, "0");
return `${minutes}:${remainder}`;
});
const start = async () =>
app.startFocusSession(workspaceSlug(), selectedMode().mode, selectedMode().seconds, selectedTaskId());
const sessions = createMemo(() =>
app.state.focusSessions.filter(session => session.workspaceSlug === workspaceSlug()),
);
return (
<div class="mx-auto max-w-2xl space-y-6">
{/* Page header */}
<div>
<h1 class="text-xl font-semibold">Focus</h1>
<p class="mt-1 text-sm text-[var(--text-muted)]">Hold the line for one clean block</p>
</div>
{/* Timer card */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)] p-6">
{/* Timer display */}
<div class="text-center">
<div class="text-6xl font-semibold tracking-tight">{display()}</div>
<p class="mt-2 text-sm text-[var(--text-muted)]">
{active() ? active()!.mode.replace("_", " ") : "Ready to start"}
</p>
</div>
{/* Preset selector */}
<div class="mt-6 flex justify-center gap-2">
<For each={presets}>
{preset => (
<button
class="rounded-md px-4 py-2 text-sm font-medium transition-colors"
classList={{
"bg-[var(--accent-subtle)] text-[var(--accent)]": selectedMode().mode === preset.mode && !Boolean(active()),
"bg-[var(--bg-subtle)] text-[var(--text-muted)] hover:text-[var(--text)]": selectedMode().mode !== preset.mode || Boolean(active())
}}
onClick={() => setSelectedMode(preset)}
disabled={Boolean(active())}
>
{preset.label}
</button>
)}
</For>
</div>
{/* Task selector */}
<div class="mt-6">
<select
class="input-base"
value={selectedTaskId()}
onInput={event => setSelectedTaskId(event.currentTarget.value)}
disabled={Boolean(active())}
>
<option value="">No task selected</option>
<For each={app.state.tasks.filter(task => task.workspaceSlug === workspaceSlug())}>
{task => <option value={task.id}>{task.title}</option>}
</For>
</select>
</div>
{/* Controls */}
<div class="mt-6 flex justify-center gap-3">
<Show when={!active()}>
<button
class="button-primary px-6 py-2 text-sm"
onClick={() => void start()}
>
Start session
</button>
</Show>
<Show when={active()}>
<button
class="button-secondary px-4 py-2 text-sm"
onClick={() => active() && (active()!.pausedAt ? app.resumeFocusSession(active()!.id) : app.pauseFocusSession(active()!.id))}
>
{active()?.pausedAt ? "Resume" : "Pause"}
</button>
<button
class="button-primary px-4 py-2 text-sm"
onClick={() => active() && app.completeFocusSession(active()!.id)}
>
Complete
</button>
</Show>
</div>
</div>
{/* Session history */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">Session history</h2>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={sessions().length === 0}>
<div class="px-4 py-6 text-center text-sm text-[var(--text-muted)]">
No sessions recorded yet
</div>
</Show>
<For each={sessions().slice(0, 10)}>
{session => (
<div class="flex items-center justify-between px-4 py-3">
<div>
<p class="text-sm font-medium">{session.mode.replace("_", " ")}</p>
<p class="text-xs text-[var(--text-muted)]">{formatStamp(session.startedAt)}</p>
</div>
<span class="text-sm text-[var(--text-muted)]">
{Math.round(session.durationSeconds / 60)} min
</span>
</div>
)}
</For>
</div>
</div>
</div>
);
}
@@ -0,0 +1,139 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { CheckCircle, Inbox, Plus, Trash2, X } from "lucide-solid";
import { useApp } from "~/lib/app-context";
import {
apiListInboxItems,
apiCreateInboxItem,
apiDeleteInboxItem
} from "~/lib/api-crm";
import type { InboxItem } from "~/lib/types-crm";
import { formatPrettyDate } from "~/lib/utils";
export default function InboxRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [items, setItems] = createSignal<InboxItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [newContent, setNewContent] = createSignal("");
const loadItems = async () => {
setLoading(true);
try {
const list = await apiListInboxItems(workspaceSlug());
setItems(list);
} catch (e) {
console.error("Failed to load inbox", e);
}
setLoading(false);
};
if (items().length === 0 && loading()) {
loadItems();
}
const handleAdd = async (e: KeyboardEvent) => {
if (e.key !== "Enter") return;
const content = newContent().trim();
if (!content) return;
const item = await apiCreateInboxItem({
workspaceSlug: workspaceSlug(),
content
});
setItems([item, ...items()]);
setNewContent("");
};
const handleDelete = async (id: string) => {
await apiDeleteInboxItem(id);
setItems(items().filter(i => i.id !== id));
};
const handleConvertToTask = async (item: InboxItem) => {
// Create task from inbox item
const task = await app.createTask({
workspaceSlug: workspaceSlug(),
boardGroupId: "group-inbox",
title: item.content,
color: "slate"
});
// Delete inbox item
await apiDeleteInboxItem(item.id);
setItems(items().filter(i => i.id !== item.id));
// Navigate to board
window.location.href = `/app/${workspaceSlug()}/board`;
};
return (
<div class="max-w-2xl mx-auto py-8">
<div class="flex items-center gap-3 mb-6">
<Inbox class="w-8 h-8 text-[var(--accent)]" />
<h1 class="text-2xl font-semibold">Inbox</h1>
</div>
<p class="text-[var(--text-muted)] mb-6">
Quick capture for ideas, tasks, and notes. Process them later.
</p>
{/* Quick add */}
<div class="relative mb-6">
<Plus class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)]" />
<input
type="text"
value={newContent()}
onInput={e => setNewContent(e.currentTarget.value)}
onKeyPress={handleAdd}
placeholder="Type something and press Enter..."
class="w-full pl-10 pr-4 py-3 rounded-lg border border-[var(--border)] bg-[var(--surface)] text-lg focus:border-[var(--accent)] focus:outline-none"
/>
</div>
{/* Items list */}
<Show when={!loading()} fallback={<div class="text-[var(--text-muted)]">Loading...</div>}>
<div class="space-y-2">
<Show when={items().length === 0}>
<div class="text-center py-12 text-[var(--text-muted)]">
<Inbox class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Your inbox is empty</p>
<p class="text-sm mt-1">Capture ideas, tasks, or notes above</p>
</div>
</Show>
<For each={items()}>
{item => (
<div class="flex items-start gap-3 p-4 rounded-lg border border-[var(--border)] bg-[var(--surface)] group">
<div class="flex-1">
<p class="text-lg">{item.content}</p>
<p class="text-sm text-[var(--text-muted)] mt-1">
{formatPrettyDate(item.createdAt)}
</p>
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleConvertToTask(item)}
class="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-[var(--accent)] text-white text-sm hover:opacity-90"
title="Convert to task"
>
<CheckCircle class="w-4 h-4" />
Task
</button>
<button
onClick={() => handleDelete(item.id)}
class="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
);
}
@@ -0,0 +1,13 @@
import { onMount } from "solid-js";
import { useNavigate, useParams } from "@solidjs/router";
export default function WorkspaceIndexRoute() {
const navigate = useNavigate();
const params = useParams();
onMount(() => {
navigate(`/app/${params.workspaceSlug}/today`, { replace: true });
});
return <div class="p-6 text-soft">Opening workspace</div>;
}
@@ -0,0 +1,439 @@
import { createSignal, createMemo, For, Show, onMount } from "solid-js";
import { Bell, BellOff, Calendar, Link, Plus, Slack, Trash2, Webhook, ExternalLink } from "lucide-solid";
import Modal from "~/components/modal";
import { useApp } from "~/lib/app-context";
import {
apiListIntegrations,
apiCreateIntegration,
apiDeleteIntegration,
apiListWebhooks,
apiCreateWebhook,
apiDeleteWebhook,
apiConnectGoogleCalendar,
apiConnectSlack,
apiDisconnectIntegration
} from "~/lib/api-integrations";
import type { Integration, Webhook } from "~/lib/types-integrations";
const integrationProviders = [
{ id: "google_calendar", name: "Google Calendar", icon: Calendar, description: "Sync events with Google Calendar", oauth: true },
{ id: "slack", name: "Slack", icon: Slack, description: "Send notifications to Slack channels", oauth: true },
{ id: "webhook", name: "Custom Webhook", icon: Webhook, description: "Send events to any HTTP endpoint", oauth: false }
];
const webhookEvents = [
{ id: "task.created", label: "Task Created" },
{ id: "task.completed", label: "Task Completed" },
{ id: "task.assigned", label: "Task Assigned" },
{ id: "event.created", label: "Event Created" },
{ id: "comment.created", label: "Comment Created" }
];
export default function IntegrationsRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [integrations, setIntegrations] = createSignal<Integration[]>([]);
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
const [loading, setLoading] = createSignal(true);
const [showAddIntegration, setShowAddIntegration] = createSignal(false);
const [showAddWebhook, setShowAddWebhook] = createSignal(false);
// Integration form
const [selectedProvider, setSelectedProvider] = createSignal("webhook");
const [integrationName, setIntegrationName] = createSignal("");
const [integrationConfig, setIntegrationConfig] = createSignal("");
const [integrationCredentials, setIntegrationCredentials] = createSignal("");
// Webhook form
const [webhookName, setWebhookName] = createSignal("");
const [webhookUrl, setWebhookUrl] = createSignal("");
const [selectedEvents, setSelectedEvents] = createSignal<string[]>(["task.created", "task.completed"]);
const loadData = async () => {
setLoading(true);
try {
const [ints, hooks] = await Promise.all([
apiListIntegrations(workspaceSlug()),
apiListWebhooks(workspaceSlug())
]);
setIntegrations(ints);
setWebhooks(hooks);
} catch (e) {
console.error("Failed to load integrations", e);
}
setLoading(false);
};
onMount(loadData);
const resetIntegrationForm = () => {
setSelectedProvider("webhook");
setIntegrationName("");
setIntegrationConfig("");
setIntegrationCredentials("");
};
const resetWebhookForm = () => {
setWebhookName("");
setWebhookUrl("");
setSelectedEvents(["task.created", "task.completed"]);
};
const handleAddIntegration = async () => {
if (!integrationName().trim() || !integrationCredentials().trim()) return;
const integration = await apiCreateIntegration({
workspaceSlug: workspaceSlug(),
provider: selectedProvider(),
name: integrationName(),
config: integrationConfig(),
credentials: integrationCredentials()
});
setIntegrations([...integrations(), integration]);
setShowAddIntegration(false);
resetIntegrationForm();
};
const handleDeleteIntegration = async (id: string) => {
await apiDeleteIntegration(id);
setIntegrations(integrations().filter(i => i.id !== id));
};
const handleConnectGoogleCalendar = async () => {
try {
const result = await apiConnectGoogleCalendar(workspaceSlug());
window.location.href = result.authUrl;
} catch (e) {
console.error("Failed to connect Google Calendar", e);
}
};
const handleConnectSlack = async () => {
try {
const result = await apiConnectSlack(workspaceSlug());
window.location.href = result.authUrl;
} catch (e) {
console.error("Failed to connect Slack", e);
}
};
const handleDisconnectIntegration = async (id: string) => {
try {
await apiDisconnectIntegration(id);
setIntegrations(integrations().filter(i => i.id !== id));
} catch (e) {
console.error("Failed to disconnect integration", e);
}
};
const handleAddWebhook = async () => {
if (!webhookName().trim() || !webhookUrl().trim()) return;
const webhook = await apiCreateWebhook({
workspaceSlug: workspaceSlug(),
name: webhookName(),
url: webhookUrl(),
events: selectedEvents()
});
setWebhooks([...webhooks(), webhook]);
setShowAddWebhook(false);
resetWebhookForm();
};
const handleDeleteWebhook = async (id: string) => {
await apiDeleteWebhook(id);
setWebhooks(webhooks().filter(w => w.id !== id));
};
const toggleEvent = (eventId: string) => {
const events = selectedEvents();
if (events.includes(eventId)) {
setSelectedEvents(events.filter(e => e !== eventId));
} else {
setSelectedEvents([...events, eventId]);
}
};
return (
<div class="max-w-4xl mx-auto space-y-8">
<div>
<h1 class="text-2xl font-semibold">Integrations</h1>
<p class="mt-1 text-sm text-[var(--text-muted)]">Connect external services and automate workflows</p>
</div>
{/* Quick Connect */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">Quick Connect</h2>
</div>
<div class="p-4 grid grid-cols-2 gap-3">
<button
onClick={handleConnectGoogleCalendar}
class="flex items-center gap-3 p-4 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)] transition-colors"
>
<Calendar class="w-6 h-6 text-blue-500" />
<div class="text-left">
<p class="font-medium">Google Calendar</p>
<p class="text-xs text-[var(--text-muted)]">Sync events bidirectionally</p>
</div>
<ExternalLink class="w-4 h-4 ml-auto text-[var(--text-muted)]" />
</button>
<button
onClick={handleConnectSlack}
class="flex items-center gap-3 p-4 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)] transition-colors"
>
<Slack class="w-6 h-6 text-purple-500" />
<div class="text-left">
<p class="font-medium">Slack</p>
<p class="text-xs text-[var(--text-muted)]">Send notifications to channels</p>
</div>
<ExternalLink class="w-4 h-4 ml-auto text-[var(--text-muted)]" />
</button>
</div>
</div>
{/* Integrations */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3 flex items-center justify-between">
<h2 class="text-sm font-medium">Connected Services</h2>
<button
onClick={() => { resetIntegrationForm(); setShowAddIntegration(true); }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--accent)] text-white text-sm hover:opacity-90"
>
<Plus class="w-4 h-4" />
Add Integration
</button>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={!loading() && integrations().length === 0}>
<div class="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
No integrations connected yet
</div>
</Show>
<For each={integrations()}>
{integration => {
const provider = integrationProviders.find(p => p.id === integration.provider);
const Icon = provider?.icon || Link;
const isOAuth = provider?.oauth;
return (
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-[var(--bg-subtle)] flex items-center justify-center">
<Icon class="w-5 h-5 text-[var(--text-muted)]" />
</div>
<div>
<p class="font-medium">{integration.name}</p>
<p class="text-sm text-[var(--text-muted)]">{provider?.name || integration.provider}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class={`px-2 py-0.5 rounded text-xs ${
integration.status === "active" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-700"
}`}>
{integration.status}
</span>
<button
onClick={() => isOAuth ? handleDisconnectIntegration(integration.id) : handleDeleteIntegration(integration.id)}
class="p-1.5 rounded text-[var(--text-muted)] hover:text-red-500 hover:bg-red-50"
title={isOAuth ? "Disconnect" : "Delete"}
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
);
}}
</For>
</div>
</div>
{/* Webhooks */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3 flex items-center justify-between">
<h2 class="text-sm font-medium">Webhooks</h2>
<button
onClick={() => { resetWebhookForm(); setShowAddWebhook(true); }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--accent)] text-white text-sm hover:opacity-90"
>
<Plus class="w-4 h-4" />
Add Webhook
</button>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={!loading() && webhooks().length === 0}>
<div class="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
No webhooks configured yet
</div>
</Show>
<For each={webhooks()}>
{webhook => (
<div class="flex items-center justify-between px-4 py-3">
<div>
<p class="font-medium">{webhook.name}</p>
<p class="text-sm text-[var(--text-muted)] truncate max-w-md">{webhook.url}</p>
<div class="flex gap-1 mt-1">
<For each={webhook.events?.slice?.(0, 3) || []}>
{(event: string) => (
<span class="px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-xs text-[var(--text-muted)]">
{event}
</span>
)}
</For>
<Show when={(webhook.events?.length || 0) > 3}>
<span class="text-xs text-[var(--text-muted)]">
+{(webhook.events?.length || 0) - 3} more
</span>
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<span class={`px-2 py-0.5 rounded text-xs ${
webhook.active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-700"
}`}>
{webhook.active ? "Active" : "Inactive"}
</span>
<button
onClick={() => handleDeleteWebhook(webhook.id)}
class="p-1.5 rounded text-[var(--text-muted)] hover:text-red-500 hover:bg-red-50"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
{/* Add Integration Modal */}
<Modal open={showAddIntegration()} onClose={() => setShowAddIntegration(false)} title="Add Integration">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Provider</label>
<div class="grid grid-cols-3 gap-2">
<For each={integrationProviders}>
{provider => (
<button
onClick={() => setSelectedProvider(provider.id)}
class={`p-3 rounded-lg border text-center transition-colors ${
selectedProvider() === provider.id
? "border-[var(--accent)] bg-[var(--accent-muted)]"
: "border-[var(--border)] hover:bg-[var(--surface-hover)]"
}`}
>
<provider.icon class="w-6 h-6 mx-auto mb-1" />
<span class="text-xs">{provider.name}</span>
</button>
)}
</For>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input
type="text"
value={integrationName()}
onInput={e => setIntegrationName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
placeholder="My Integration"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Configuration (JSON)</label>
<textarea
value={integrationConfig()}
onInput={e => setIntegrationConfig(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] min-h-[60px] font-mono text-sm"
placeholder='{"calendar_id": "primary"}'
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Credentials</label>
<textarea
value={integrationCredentials()}
onInput={e => setIntegrationCredentials(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] min-h-[60px] font-mono text-sm"
placeholder="API key or OAuth token"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowAddIntegration(false)}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)]"
>
Cancel
</button>
<button
onClick={handleAddIntegration}
class="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
Add Integration
</button>
</div>
</div>
</Modal>
{/* Add Webhook Modal */}
<Modal open={showAddWebhook()} onClose={() => setShowAddWebhook(false)} title="Add Webhook">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input
type="text"
value={webhookName()}
onInput={e => setWebhookName(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
placeholder="Slack Notifications"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">URL</label>
<input
type="url"
value={webhookUrl()}
onInput={e => setWebhookUrl(e.currentTarget.value)}
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--surface)]"
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Events</label>
<div class="space-y-2">
<For each={webhookEvents}>
{event => (
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedEvents().includes(event.id)}
onChange={() => toggleEvent(event.id)}
class="rounded"
/>
<span class="text-sm">{event.label}</span>
</label>
)}
</For>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
onClick={() => setShowAddWebhook(false)}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)]"
>
Cancel
</button>
<button
onClick={handleAddWebhook}
class="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
Add Webhook
</button>
</div>
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,305 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { format, isToday, isPast, parseISO } from "date-fns";
import { ArrowUpDown, CheckCircle, Circle, Filter, LayoutList, Plus } from "lucide-solid";
import Modal from "~/components/modal";
import { useApp } from "~/lib/app-context";
import { colorOptions, getColorStyle } from "~/lib/utils";
type SortField = "title" | "status" | "dueAt" | "createdAt";
type SortDir = "asc" | "desc";
export default function ListRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [sortField, setSortField] = createSignal<SortField>("createdAt");
const [sortDir, setSortDir] = createSignal<SortDir>("desc");
const [statusFilter, setStatusFilter] = createSignal<string>("all");
const [showCompleted, setShowCompleted] = createSignal(false);
const [selectedTaskId, setSelectedTaskId] = createSignal<string | null>(null);
const tasks = createMemo(() =>
app.state.tasks.filter(t => t.workspaceSlug === workspaceSlug() && !t.archived)
);
const groups = createMemo(() =>
[...app.state.boardGroups]
.filter(g => g.workspaceSlug === workspaceSlug())
.sort((a, b) => a.order - b.order)
);
const labels = createMemo(() =>
app.state.labels.filter(l => l.workspaceSlug === workspaceSlug())
);
const filteredTasks = createMemo(() => {
let result = tasks();
// Status filter
const status = statusFilter();
if (status !== "all") {
result = result.filter(t => t.status === status);
}
// Show/hide completed
if (!showCompleted()) {
result = result.filter(t => t.status !== "done");
}
// Sort
const field = sortField();
const dir = sortDir();
result = [...result].sort((a, b) => {
let cmp = 0;
switch (field) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "status":
cmp = a.status.localeCompare(b.status);
break;
case "dueAt":
cmp = (a.dueAt || "").localeCompare(b.dueAt || "");
break;
case "createdAt":
cmp = a.createdAt.localeCompare(b.createdAt);
break;
}
return dir === "asc" ? cmp : -cmp;
});
return result;
});
const toggleSort = (field: SortField) => {
if (sortField() === field) {
setSortDir(sortDir() === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDir("asc");
}
};
const selectedTask = createMemo(() =>
tasks().find(t => t.id === selectedTaskId())
);
const getGroupName = (groupId: string) => {
const group = groups().find(g => g.id === groupId);
return group?.name || "Inbox";
};
const getLabelNames = (labelIds: string[]) => {
return labelIds
.map(id => labels().find(l => l.id === id)?.name)
.filter(Boolean)
.join(", ");
};
return (
<div class="flex flex-col h-full">
{/* Header */}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<LayoutList class="w-6 h-6 text-[var(--accent)]" />
<h1 class="text-2xl font-semibold">List View</h1>
</div>
<div class="flex items-center gap-2">
<select
value={statusFilter()}
onChange={e => setStatusFilter(e.currentTarget.value)}
class="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] text-sm"
>
<option value="all">All Status</option>
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showCompleted()}
onChange={e => setShowCompleted(e.currentTarget.checked)}
class="rounded"
/>
Show completed
</label>
</div>
</div>
{/* Table */}
<div class="flex-1 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<table class="w-full">
<thead class="sticky top-0 bg-[var(--surface)] border-b border-[var(--border)]">
<tr>
<th class="w-8 px-4 py-3"></th>
<th
class="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-[var(--surface-hover)]"
onClick={() => toggleSort("title")}
>
<div class="flex items-center gap-1">
Title
<Show when={sortField() === "title"}>
<ArrowUpDown class="w-3 h-3" />
</Show>
</div>
</th>
<th class="px-4 py-3 text-left text-sm font-medium">Group</th>
<th
class="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-[var(--surface-hover)]"
onClick={() => toggleSort("status")}
>
<div class="flex items-center gap-1">
Status
<Show when={sortField() === "status"}>
<ArrowUpDown class="w-3 h-3" />
</Show>
</div>
</th>
<th
class="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-[var(--surface-hover)]"
onClick={() => toggleSort("dueAt")}
>
<div class="flex items-center gap-1">
Due
<Show when={sortField() === "dueAt"}>
<ArrowUpDown class="w-3 h-3" />
</Show>
</div>
</th>
<th class="px-4 py-3 text-left text-sm font-medium">Labels</th>
</tr>
</thead>
<tbody>
<For each={filteredTasks()}>
{task => (
<tr
class="border-b border-[var(--border)] hover:bg-[var(--surface-hover)] cursor-pointer"
onClick={() => setSelectedTaskId(task.id)}
>
<td class="px-4 py-3">
<button
onClick={e => {
e.stopPropagation();
app.updateTask(task.id, {
status: task.status === "done" ? "todo" : "done"
});
}}
class="text-[var(--text-muted)] hover:text-[var(--accent)]"
>
<Show when={task.status === "done"} fallback={<Circle class="w-5 h-5" />}>
<CheckCircle class="w-5 h-5 text-green-500" />
</Show>
</button>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
style={getColorStyle(task.color)}
/>
<span class={task.status === "done" ? "line-through text-[var(--text-muted)]" : ""}>
{task.title}
</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-muted)]">
{getGroupName(task.boardGroupId)}
</td>
<td class="px-4 py-3">
<span class={`px-2 py-0.5 rounded text-xs ${
task.status === "done" ? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300" :
task.status === "in_progress" ? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" :
"bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
}`}>
{task.status === "in_progress" ? "In Progress" : task.status.charAt(0).toUpperCase() + task.status.slice(1)}
</span>
</td>
<td class="px-4 py-3 text-sm">
<Show when={task.dueAt}>
<span class={`${
isPast(parseISO(task.dueAt!)) && task.status !== "done"
? "text-red-500"
: isToday(parseISO(task.dueAt!))
? "text-[var(--accent)]"
: "text-[var(--text-muted)]"
}`}>
{format(parseISO(task.dueAt!), "MMM d")}
</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-muted)]">
{getLabelNames(task.labelIds)}
</td>
</tr>
)}
</For>
</tbody>
</table>
<Show when={filteredTasks().length === 0}>
<div class="text-center py-12 text-[var(--text-muted)]">
No tasks found
</div>
</Show>
</div>
{/* Task detail modal - simplified */}
<Modal
open={selectedTaskId() !== null}
onClose={() => setSelectedTaskId(null)}
title="Task Details"
>
<Show when={selectedTask()}>
{task => (
<div class="space-y-4">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full"
style={getColorStyle(task.color)}
/>
<h3 class="text-lg font-medium">{task.title}</h3>
</div>
<Show when={task.description}>
<p class="text-[var(--text-muted)]">{task.description}</p>
</Show>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-[var(--text-muted)]">Status:</span>{" "}
<span class="capitalize">{task.status.replace("_", " ")}</span>
</div>
<div>
<span class="text-[var(--text-muted)]">Group:</span>{" "}
{getGroupName(task.boardGroupId)}
</div>
<Show when={task.dueAt}>
<div>
<span class="text-[var(--text-muted)]">Due:</span>{" "}
{format(parseISO(task.dueAt!), "MMM d, yyyy")}
</div>
</Show>
</div>
<div class="flex justify-end gap-2 pt-4">
<button
onClick={() => setSelectedTaskId(null)}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)]"
>
Close
</button>
<button
onClick={() => {
window.location.href = `/app/${workspaceSlug()}/board`;
}}
class="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
Open in Board
</button>
</div>
</div>
)}
</Show>
</Modal>
</div>
);
}
@@ -0,0 +1,424 @@
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js";
import { useParams } from "@solidjs/router";
import { RefreshCcw, Send, ServerCog, Plus } from "lucide-solid";
import {
apiConnectMailbox,
apiCreateOutgoingMail,
apiCreateTaskFromMail,
apiListMailMessages,
apiListMailboxes,
apiListOutgoingMails,
apiSyncMailbox
} from "~/lib/api";
import { useApp } from "~/lib/app-context";
import type { MailAddress, MailMessage, Mailbox, OutgoingMail } from "~/lib/types";
import { formatPrettyDate } from "~/lib/utils";
const emptyConnectForm = {
label: "",
email: "",
displayName: "",
imapHost: "",
imapPort: 993,
imapUsername: "",
imapPassword: "",
imapUseTls: true,
smtpHost: "",
smtpPort: 587,
smtpUsername: "",
smtpPassword: "",
smtpUseTls: true
};
const emptyComposeForm = {
to: "",
cc: "",
bcc: "",
subject: "",
textBody: "",
htmlBody: "",
scheduledFor: ""
};
export default function MailRoute() {
const app = useApp();
const params = useParams();
const workspaceSlug = () => params.workspaceSlug || app.primaryWorkspace()?.slug || "personal";
const [loading, setLoading] = createSignal(true);
const [savingMailbox, setSavingMailbox] = createSignal(false);
const [sendingMail, setSendingMail] = createSignal(false);
const [status, setStatus] = createSignal("");
const [mailboxes, setMailboxes] = createSignal<Mailbox[]>([]);
const [messages, setMessages] = createSignal<MailMessage[]>([]);
const [outgoing, setOutgoing] = createSignal<OutgoingMail[]>([]);
const [selectedMailboxId, setSelectedMailboxId] = createSignal("");
const [selectedMessageId, setSelectedMessageId] = createSignal("");
const [taskBoardGroupId, setTaskBoardGroupId] = createSignal("group-inbox");
const [connectForm, setConnectForm] = createSignal(emptyConnectForm);
const [composeForm, setComposeForm] = createSignal(emptyComposeForm);
const [showConnect, setShowConnect] = createSignal(false);
const [showCompose, setShowCompose] = createSignal(false);
const workspaceBoardGroups = createMemo(() =>
app.state.boardGroups.filter(group => group.workspaceSlug === workspaceSlug()),
);
const selectedMessage = createMemo(() => messages().find(message => message.id === selectedMessageId()));
createEffect(() => {
const groups = workspaceBoardGroups();
if (!groups.length) return;
if (!groups.some(group => group.id === taskBoardGroupId())) {
setTaskBoardGroupId(groups[0].id);
}
});
onMount(() => {
void loadMailData();
});
async function loadMailData(preferredMailboxId?: string) {
setLoading(true);
try {
const nextMailboxes = await apiListMailboxes(workspaceSlug());
setMailboxes(nextMailboxes);
const mailboxId = preferredMailboxId || selectedMailboxId() || nextMailboxes[0]?.id || "";
setSelectedMailboxId(mailboxId);
const [nextMessages, nextOutgoing] = await Promise.all([
apiListMailMessages(workspaceSlug(), mailboxId || undefined),
apiListOutgoingMails(workspaceSlug(), mailboxId || undefined)
]);
setMessages(nextMessages);
setOutgoing(nextOutgoing);
if (!nextMessages.some(message => message.id === selectedMessageId())) {
setSelectedMessageId(nextMessages[0]?.id ?? "");
}
setStatus("");
} catch (error) {
setStatus(errorMessage(error));
} finally {
setLoading(false);
}
}
async function connectMailbox() {
setSavingMailbox(true);
setStatus("");
try {
const created = await apiConnectMailbox({
workspaceSlug: workspaceSlug(),
...connectForm()
});
setConnectForm(emptyConnectForm);
setShowConnect(false);
await loadMailData(created?.id);
setStatus(created ? `Connected ${created.email}.` : "Mailbox connected.");
} catch (error) {
setStatus(errorMessage(error));
} finally {
setSavingMailbox(false);
}
}
async function syncMailbox() {
if (!selectedMailboxId()) return;
setStatus("");
try {
await apiSyncMailbox(selectedMailboxId());
await loadMailData(selectedMailboxId());
setStatus("Mailbox synced.");
} catch (error) {
setStatus(errorMessage(error));
}
}
async function sendMail() {
if (!selectedMailboxId()) {
setStatus("Connect a mailbox before sending mail.");
return;
}
setSendingMail(true);
setStatus("");
try {
await apiCreateOutgoingMail({
workspaceSlug: workspaceSlug(),
mailboxId: selectedMailboxId(),
to: parseAddressList(composeForm().to),
cc: parseAddressList(composeForm().cc),
bcc: parseAddressList(composeForm().bcc),
subject: composeForm().subject,
textBody: composeForm().textBody,
htmlBody: composeForm().htmlBody,
scheduledFor: composeForm().scheduledFor ? new Date(composeForm().scheduledFor).toISOString() : undefined
});
setComposeForm(emptyComposeForm);
setShowCompose(false);
await loadMailData(selectedMailboxId());
setStatus("Outgoing mail queued.");
} catch (error) {
setStatus(errorMessage(error));
} finally {
setSendingMail(false);
}
}
async function createTaskFromMessage() {
const message = selectedMessage();
if (!message) return;
setStatus("");
try {
await apiCreateTaskFromMail(message.id, {
boardGroupId: taskBoardGroupId(),
title: message.subject
});
await loadMailData(selectedMailboxId());
setStatus("Task created from mail.");
} catch (error) {
setStatus(errorMessage(error));
}
}
return (
<div class="flex h-full gap-4">
{/* Sidebar: Mailboxes */}
<aside class="w-56 shrink-0 space-y-4">
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] p-3">
<div class="flex items-center justify-between">
<h1 class="text-sm font-semibold">Mail</h1>
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-subtle)] hover:text-[var(--text)]"
onClick={() => setShowConnect(!showConnect())}
aria-label="Add mailbox"
>
<Plus size={14} />
</button>
</div>
</div>
<div class="divide-y divide-[var(--border)]">
<For each={mailboxes()}>
{mailbox => (
<button
class="w-full px-3 py-2 text-left transition-colors"
classList={{
"bg-[var(--accent-subtle)]": mailbox.id === selectedMailboxId(),
"hover:bg-[var(--bg-subtle)]": mailbox.id !== selectedMailboxId()
}}
onClick={() => {
setSelectedMailboxId(mailbox.id);
void loadMailData(mailbox.id);
}}
>
<p class="truncate text-sm font-medium">{mailbox.label}</p>
<p class="truncate text-xs text-[var(--text-muted)]">{mailbox.email}</p>
</button>
)}
</For>
<Show when={mailboxes().length === 0}>
<div class="px-3 py-4 text-center text-xs text-[var(--text-muted)]">
No mailboxes
</div>
</Show>
</div>
</div>
{/* Actions */}
<div class="space-y-2">
<button
class="button-secondary flex w-full items-center justify-center gap-2 px-3 py-2 text-xs"
onClick={() => void loadMailData(selectedMailboxId())}
>
<RefreshCcw size={12} />
Refresh
</button>
<button
class="button-secondary flex w-full items-center justify-center gap-2 px-3 py-2 text-xs"
onClick={syncMailbox}
disabled={!selectedMailboxId()}
>
<ServerCog size={12} />
Sync
</button>
<button
class="button-primary flex w-full items-center justify-center gap-2 px-3 py-2 text-xs"
onClick={() => setShowCompose(true)}
disabled={!selectedMailboxId()}
>
<Send size={12} />
Compose
</button>
</div>
{/* Status */}
<Show when={status()}>
<div class="rounded-md bg-[var(--bg-subtle)] px-3 py-2 text-xs text-[var(--text-muted)]">
{status()}
</div>
</Show>
</aside>
{/* Message list */}
<div class="w-80 shrink-0 rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-3 py-2">
<h2 class="text-sm font-medium">Inbox</h2>
</div>
<div class="divide-y divide-[var(--border)] overflow-y-auto">
<Show when={loading()}>
<div class="px-3 py-4 text-center text-sm text-[var(--text-muted)]">Loading...</div>
</Show>
<Show when={!loading() && messages().length === 0}>
<div class="px-3 py-8 text-center text-sm text-[var(--text-muted)]">
No messages
</div>
</Show>
<For each={messages()}>
{message => (
<button
class="w-full px-3 py-2 text-left transition-colors"
classList={{
"bg-[var(--accent-subtle)]": message.id === selectedMessageId(),
"hover:bg-[var(--bg-subtle)]": message.id !== selectedMessageId()
}}
onClick={() => setSelectedMessageId(message.id)}
>
<p class="truncate text-sm font-medium">{message.subject || "(no subject)"}</p>
<p class="truncate text-xs text-[var(--text-muted)]">
{message.from.name || message.from.email}
</p>
<p class="mt-1 text-xs text-[var(--text-subtle)]">
{formatPrettyDate(message.receivedAt)}
</p>
</button>
)}
</For>
</div>
</div>
{/* Message detail */}
<div class="flex-1 rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<Show
when={selectedMessage()}
fallback={
<div class="flex h-full items-center justify-center text-sm text-[var(--text-muted)]">
Select a message to view
</div>
}
>
{message => (
<div class="flex h-full flex-col">
{/* Header */}
<div class="border-b border-[var(--border)] px-4 py-3">
<h3 class="text-sm font-semibold">{message().subject || "(no subject)"}</h3>
<p class="mt-1 text-xs text-[var(--text-muted)]">
From: {message().from.name ? `${message().from.name} <${message().from.email}>` : message().from.email}
</p>
<p class="text-xs text-[var(--text-muted)]">
To: {message().to.map(a => a.email).join(", ")}
</p>
</div>
{/* Body */}
<div class="flex-1 overflow-y-auto p-4">
<pre class="whitespace-pre-wrap text-sm text-[var(--text-muted)]">
{message().textBody || message().snippet}
</pre>
</div>
{/* Actions */}
<div class="border-t border-[var(--border)] px-4 py-3">
<div class="flex items-center gap-2">
<select
class="input-base h-8 text-xs"
value={taskBoardGroupId()}
onInput={event => setTaskBoardGroupId(event.currentTarget.value)}
>
<For each={workspaceBoardGroups()}>
{group => <option value={group.id}>{group.name}</option>}
</For>
</select>
<button
class="button-secondary px-3 py-1.5 text-xs"
onClick={createTaskFromMessage}
disabled={Boolean(message().linkedTaskId)}
>
{message().linkedTaskId ? "Task linked" : "Create task"}
</button>
</div>
</div>
</div>
)}
</Show>
</div>
{/* Connect mailbox modal */}
<Show when={showConnect()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowConnect(false)}>
<div class="w-full max-w-lg rounded-lg border border-[var(--border)] bg-[var(--surface)] p-4" onClick={e => e.stopPropagation()}>
<h3 class="mb-4 text-sm font-semibold">Connect Mailbox</h3>
<div class="grid gap-2 text-xs">
<input class="input-base" placeholder="Label" value={connectForm().label} onInput={event => setConnectForm(form => ({ ...form, label: event.currentTarget.value }))} />
<input class="input-base" placeholder="Email" value={connectForm().email} onInput={event => setConnectForm(form => ({ ...form, email: event.currentTarget.value }))} />
<input class="input-base" placeholder="IMAP host" value={connectForm().imapHost} onInput={event => setConnectForm(form => ({ ...form, imapHost: event.currentTarget.value }))} />
<input class="input-base" placeholder="IMAP username" value={connectForm().imapUsername} onInput={event => setConnectForm(form => ({ ...form, imapUsername: event.currentTarget.value }))} />
<input class="input-base" type="password" placeholder="IMAP password" value={connectForm().imapPassword} onInput={event => setConnectForm(form => ({ ...form, imapPassword: event.currentTarget.value }))} />
<input class="input-base" placeholder="SMTP host" value={connectForm().smtpHost} onInput={event => setConnectForm(form => ({ ...form, smtpHost: event.currentTarget.value }))} />
<input class="input-base" type="password" placeholder="SMTP password (optional)" value={connectForm().smtpPassword} onInput={event => setConnectForm(form => ({ ...form, smtpPassword: event.currentTarget.value }))} />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="button-secondary px-3 py-1.5 text-xs" onClick={() => setShowConnect(false)}>Cancel</button>
<button class="button-primary px-3 py-1.5 text-xs" onClick={connectMailbox} disabled={savingMailbox()}>
{savingMailbox() ? "Connecting..." : "Connect"}
</button>
</div>
</div>
</div>
</Show>
{/* Compose modal */}
<Show when={showCompose()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCompose(false)}>
<div class="w-full max-w-lg rounded-lg border border-[var(--border)] bg-[var(--surface)] p-4" onClick={e => e.stopPropagation()}>
<h3 class="mb-4 text-sm font-semibold">Compose</h3>
<div class="grid gap-2 text-xs">
<input class="input-base" placeholder="To" value={composeForm().to} onInput={event => setComposeForm(form => ({ ...form, to: event.currentTarget.value }))} />
<input class="input-base" placeholder="Subject" value={composeForm().subject} onInput={event => setComposeForm(form => ({ ...form, subject: event.currentTarget.value }))} />
<textarea class="input-base min-h-32" placeholder="Message" value={composeForm().textBody} onInput={event => setComposeForm(form => ({ ...form, textBody: event.currentTarget.value }))} />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="button-secondary px-3 py-1.5 text-xs" onClick={() => setShowCompose(false)}>Cancel</button>
<button class="button-primary px-3 py-1.5 text-xs" onClick={sendMail} disabled={sendingMail()}>
{sendingMail() ? "Sending..." : "Send"}
</button>
</div>
</div>
</div>
</Show>
</div>
);
}
function parseAddressList(value: string): MailAddress[] {
return value
.split(",")
.map(item => item.trim())
.filter(Boolean)
.map(item => {
const match = item.match(/^(.*)<([^>]+)>$/);
if (!match) {
return { name: "", email: item };
}
return {
name: match[1].trim().replace(/^"|"$/g, ""),
email: match[2].trim()
};
});
}
function errorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return "Mail action failed.";
}
@@ -0,0 +1,161 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import MarkdownPreview from "~/components/markdown-preview";
import { useApp } from "~/lib/app-context";
import { formatStamp } from "~/lib/utils";
export default function NotesRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const notes = createMemo(() => app.state.notes.filter(note => note.workspaceSlug === workspaceSlug()));
const [selectedId, setSelectedId] = createSignal<string>(notes()[0]?.id ?? "");
const selected = createMemo(() => notes().find(note => note.id === selectedId()) ?? notes()[0]);
const selectedSync = createMemo(() => app.noteSyncFor(selected()?.id));
const syncBadge = createMemo(() => {
const sync = selectedSync();
if (!sync) {
return {
label: app.online() ? "No local changes" : "Offline",
detail: app.online() ? "Autosave is ready." : "Changes will queue locally.",
};
}
switch (sync.status) {
case "saving":
return { label: "Saving...", detail: sync.message };
case "synced":
return { label: "Synced", detail: `${sync.message}${formatStamp(sync.updatedAt)}` };
case "queued":
return { label: "Queued", detail: sync.message };
case "error":
return { label: "Sync issue", detail: sync.message };
default:
return { label: "No local changes", detail: "Autosave is ready." };
}
});
const createNote = async () => {
const note = await app.createNote(workspaceSlug());
setSelectedId(note.id);
};
return (
<div class="flex h-full gap-4">
{/* Note list sidebar */}
<aside class="w-64 shrink-0 rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] p-3">
<div class="flex items-center justify-between">
<h1 class="text-sm font-semibold">Notes</h1>
<button
class="flex h-7 w-7 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-subtle)] hover:text-[var(--text)]"
onClick={() => void createNote()}
aria-label="New note"
>
+
</button>
</div>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={notes().length === 0}>
<div class="px-3 py-8 text-center text-sm text-[var(--text-muted)]">
No notes yet
</div>
</Show>
<For each={notes()}>
{note => (
<button
class="w-full px-3 py-2.5 text-left transition-colors"
classList={{
"bg-[var(--accent-subtle)]": selected()?.id === note.id,
"hover:bg-[var(--bg-subtle)]": selected()?.id !== note.id
}}
onClick={() => setSelectedId(note.id)}
>
<p class="truncate text-sm font-medium">{note.title || "Untitled"}</p>
<p class="mt-0.5 text-xs text-[var(--text-muted)]">
{formatStamp(note.updatedAt)}
</p>
</button>
)}
</For>
</div>
</aside>
{/* Editor area */}
<div class="flex flex-1 flex-col gap-4">
{/* Sync status bar */}
<div class="flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2">
<div class="flex items-center gap-2">
<span
class="h-2 w-2 rounded-full"
classList={{
"bg-[var(--success)]": syncBadge().label === "Synced",
"bg-[var(--warning)]": syncBadge().label === "Saving..." || syncBadge().label === "Queued",
"bg-[var(--error)]": syncBadge().label === "Sync issue",
"bg-[var(--text-muted)]": syncBadge().label === "No local changes" || syncBadge().label === "Offline"
}}
/>
<span class="text-xs font-medium">{syncBadge().label}</span>
</div>
<span class="text-xs text-[var(--text-muted)]">{syncBadge().detail}</span>
</div>
{/* Editor */}
<Show when={selected()}>
<div class="grid flex-1 gap-4 lg:grid-cols-2">
{/* Editor pane */}
<div class="flex flex-col rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-2">
<input
class="w-full bg-transparent text-sm font-medium outline-none"
placeholder="Note title"
value={selected()?.title ?? ""}
onInput={event => {
const note = selected();
if (!note) return;
app.updateNote(note.id, event.currentTarget.value, note.content);
}}
/>
</div>
<textarea
class="min-h-0 flex-1 resize-none bg-transparent p-4 text-sm outline-none"
placeholder="Start writing..."
value={selected()?.content ?? ""}
onInput={event => {
const note = selected();
if (!note) return;
app.updateNote(note.id, note.title, event.currentTarget.value);
}}
/>
</div>
{/* Preview pane */}
<div class="flex flex-col rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-2">
<span class="text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">Preview</span>
</div>
<div class="flex-1 overflow-y-auto p-4">
<MarkdownPreview content={selected()?.content ?? ""} />
</div>
</div>
</div>
</Show>
<Show when={!selected()}>
<div class="flex flex-1 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="text-center">
<p class="text-sm text-[var(--text-muted)]">Select a note or create a new one</p>
<button
class="button-primary mt-4 px-4 py-2 text-sm"
onClick={() => void createNote()}
>
New note
</button>
</div>
</div>
</Show>
</div>
</div>
);
}
@@ -0,0 +1,256 @@
import { createSignal, For, Show } from "solid-js";
import { useApp } from "~/lib/app-context";
import { colorOptions, getColorStyle } from "~/lib/utils";
export default function SettingsRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const currentMemberRole = () =>
app.state.members.find(member => member.workspaceSlug === workspaceSlug() && member.email === app.state.session?.email)?.role ??
app.primaryWorkspace()?.role ??
"member";
const canManageMembers = () => currentMemberRole() === "owner" || currentMemberRole() === "admin";
const [inviteEmail, setInviteEmail] = createSignal("");
const [labelName, setLabelName] = createSignal("");
const [labelColor, setLabelColor] = createSignal("rose");
const [memberNotice, setMemberNotice] = createSignal<string>("");
const [inviteNotice, setInviteNotice] = createSignal<string>("");
const createInvite = async () => {
if (!inviteEmail().trim()) return;
const saved = await app.createInvite(workspaceSlug(), inviteEmail(), "member");
if (!saved) {
setInviteNotice("Invite could not be created. Check connection and permissions.");
return;
}
setInviteNotice("");
setInviteEmail("");
};
const updateMemberRole = async (memberId: string, role: "owner" | "admin" | "member") => {
const updated = await app.updateMember(memberId, { role });
setMemberNotice(updated ? "" : "Member role update failed. Confirm owner/admin access.");
};
const toggleMemberStatus = async (memberId: string, nextStatus: "active" | "removed") => {
const updated = await app.updateMember(memberId, { status: nextStatus });
setMemberNotice(updated ? "" : "Member status update failed. At least one active owner is required.");
};
const revokeInvite = async (inviteId: string) => {
const ok = await app.revokeInvite(inviteId);
setInviteNotice(ok ? "" : "Invite revoke failed. It may already be accepted or permissions are missing.");
};
const addLabel = () => {
if (!labelName().trim()) return;
app.addLabel(workspaceSlug(), labelName(), labelColor());
setLabelName("");
};
return (
<div class="mx-auto max-w-2xl space-y-6">
{/* Page header */}
<div>
<h1 class="text-xl font-semibold">Settings</h1>
<p class="mt-1 text-sm text-[var(--text-muted)]">Manage workspace preferences</p>
</div>
{/* Theme */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">Theme</h2>
</div>
<div class="p-4">
<div class="flex gap-2">
<For each={["light", "dark"] as const}>
{mode => (
<button
class="rounded-md px-4 py-2 text-sm font-medium transition-colors"
classList={{
"bg-[var(--accent-subtle)] text-[var(--accent)]": app.state.theme === mode,
"bg-[var(--bg-subtle)] text-[var(--text-muted)] hover:text-[var(--text)]": app.state.theme !== mode
}}
onClick={() => app.setTheme(mode)}
>
{mode}
</button>
)}
</For>
</div>
</div>
</div>
{/* Labels */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">Labels</h2>
</div>
<div class="p-4">
<div class="mb-4 flex gap-2">
<input
class="input-base flex-1"
placeholder="New label"
value={labelName()}
onInput={event => setLabelName(event.currentTarget.value)}
onKeyDown={event => event.key === "Enter" && addLabel()}
/>
<select
class="input-base w-28"
value={labelColor()}
onInput={event => setLabelColor(event.currentTarget.value)}
>
<For each={colorOptions}>{option => <option value={option}>{option}</option>}</For>
</select>
<button class="button-primary px-4 py-2 text-sm" onClick={addLabel}>
Add
</button>
</div>
<div class="flex flex-wrap gap-2">
<For each={app.state.labels.filter(label => label.workspaceSlug === workspaceSlug())}>
{label => (
<span
class="rounded px-2 py-1 text-xs font-medium"
style={{ background: getColorStyle(label.color).bg, color: getColorStyle(label.color).text }}
>
{label.name}
</span>
)}
</For>
<Show when={app.state.labels.filter(label => label.workspaceSlug === workspaceSlug()).length === 0}>
<span class="text-xs text-[var(--text-muted)]">No labels yet</span>
</Show>
</div>
</div>
</div>
{/* Members */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">Members</h2>
</div>
<Show when={memberNotice()}>
<div class="border-b border-[var(--border)] bg-[var(--warning-subtle)] px-4 py-2 text-xs text-[var(--warning)]">
{memberNotice()}
</div>
</Show>
<div class="divide-y divide-[var(--border)]">
<For each={app.state.members.filter(member => member.workspaceSlug === workspaceSlug())}>
{member => (
<div class="flex items-center justify-between px-4 py-3">
<div>
<p class="text-sm font-medium">{member.name}</p>
<p class="text-xs text-[var(--text-muted)]">{member.email}</p>
</div>
<div class="flex items-center gap-2">
<Show
when={canManageMembers()}
fallback={
<span class="text-xs text-[var(--text-muted)]">
{member.role} · {member.status}
</span>
}
>
<select
class="input-base h-7 w-24 text-xs"
value={member.role}
onInput={event => void updateMemberRole(member.id, event.currentTarget.value as "owner" | "admin" | "member")}
disabled={member.status !== "active"}
>
<option value="owner">owner</option>
<option value="admin">admin</option>
<option value="member">member</option>
</select>
<button
class="button-secondary h-7 px-2 text-xs"
disabled={member.email === app.state.session?.email}
onClick={() => void toggleMemberStatus(member.id, member.status === "active" ? "removed" : "active")}
>
{member.status === "active" ? "Remove" : "Restore"}
</button>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
{/* Invites */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">Invites</h2>
</div>
<div class="border-b border-[var(--border)] p-4">
<div class="flex gap-2">
<input
class="input-base flex-1"
placeholder="teammate@company.com"
value={inviteEmail()}
onInput={event => setInviteEmail(event.currentTarget.value)}
onKeyDown={event => event.key === "Enter" && void createInvite()}
/>
<button
class="button-primary px-4 py-2 text-sm"
onClick={() => void createInvite()}
disabled={!canManageMembers()}
>
Invite
</button>
</div>
<Show when={inviteNotice()}>
<p class="mt-2 text-xs text-[var(--warning)]">{inviteNotice()}</p>
</Show>
</div>
<div class="divide-y divide-[var(--border)]">
<For each={app.state.invites.filter(invite => invite.workspaceSlug === workspaceSlug())}>
{invite => (
<div class="flex items-center justify-between px-4 py-3">
<div>
<p class="text-sm font-medium">{invite.email}</p>
<p class="text-xs text-[var(--text-muted)]">{invite.role} · {invite.status}</p>
</div>
<Show when={canManageMembers() && invite.status === "pending"}>
<button
class="button-secondary h-7 px-2 text-xs"
onClick={() => void revokeInvite(invite.id)}
>
Revoke
</button>
</Show>
</div>
)}
</For>
<Show when={app.state.invites.filter(invite => invite.workspaceSlug === workspaceSlug()).length === 0}>
<div class="px-4 py-3 text-center text-xs text-[var(--text-muted)]">
No pending invites
</div>
</Show>
</div>
</div>
{/* Offline queue */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div class="border-b border-[var(--border)] px-4 py-3">
<h2 class="text-sm font-medium">Offline Queue</h2>
</div>
<div class="divide-y divide-[var(--border)]">
<For each={app.state.offlineQueue}>
{item => (
<div class="flex items-center justify-between px-4 py-3">
<p class="text-sm font-medium">{item.description}</p>
<span class="text-xs text-[var(--text-muted)]">{item.status}</span>
</div>
)}
</For>
<Show when={app.state.offlineQueue.length === 0}>
<div class="px-4 py-3 text-center text-xs text-[var(--text-muted)]">
Everything is in sync
</div>
</Show>
</div>
</div>
</div>
);
}
@@ -0,0 +1,183 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { format, addDays, subDays, startOfWeek, addWeeks, subWeeks, parseISO, isSameDay, isWithinInterval } from "date-fns";
import { ChevronLeft, ChevronRight, Circle, CheckCircle } from "lucide-solid";
import { useApp } from "~/lib/app-context";
import { getColorStyle } from "~/lib/utils";
export default function TimelineRoute() {
const app = useApp();
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const [cursorDate, setCursorDate] = createSignal(new Date());
const tasks = createMemo(() =>
app.state.tasks.filter(t => t.workspaceSlug === workspaceSlug() && (t.dueAt || t.scheduledStart))
);
const events = createMemo(() =>
app.state.events.filter(e => e.workspaceSlug === workspaceSlug())
);
const visibleDays = createMemo(() => {
const start = startOfWeek(cursorDate(), { weekStartsOn: 1 });
return Array.from({ length: 14 }, (_, i) => addDays(start, i));
});
const rangeLabel = createMemo(() => {
const days = visibleDays();
const start = days[0];
const end = days[days.length - 1];
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
});
const itemsForDay = (day: Date) => {
const taskItems = tasks()
.filter(task => {
if (task.dueAt && isSameDay(parseISO(task.dueAt), day)) return true;
if (task.scheduledStart && isSameDay(parseISO(task.scheduledStart), day)) return true;
return false;
})
.map(task => ({
kind: "task" as const,
id: task.id,
title: task.title,
color: task.color,
status: task.status,
date: task.dueAt || task.scheduledStart || ""
}));
const eventItems = events()
.filter(event => isSameDay(parseISO(event.startsAt), day))
.map(event => ({
kind: "event" as const,
id: event.id,
title: event.title,
color: event.color,
status: "",
date: event.startsAt
}));
return [...taskItems, ...eventItems].sort((a, b) => a.date.localeCompare(b.date));
};
const navigate = (direction: "prev" | "next") => {
setCursorDate(direction === "next" ? addWeeks(cursorDate(), 1) : subWeeks(cursorDate(), 1));
};
const goToToday = () => setCursorDate(new Date());
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold">Timeline</h1>
<p class="mt-1 text-sm text-[var(--text-muted)]">Visual project overview</p>
</div>
<div class="flex items-center gap-3">
<button
onClick={goToToday}
class="px-3 py-1.5 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--surface-hover)]"
>
Today
</button>
<div class="flex items-center gap-2">
<button
onClick={() => navigate("prev")}
class="p-1.5 rounded-lg hover:bg-[var(--surface-hover)]"
>
<ChevronLeft class="w-5 h-5" />
</button>
<span class="text-sm font-medium min-w-[160px] text-center">{rangeLabel()}</span>
<button
onClick={() => navigate("next")}
class="p-1.5 rounded-lg hover:bg-[var(--surface-hover)]"
>
<ChevronRight class="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Timeline grid */}
<div class="rounded-lg border border-[var(--border)] bg-[var(--surface)] overflow-hidden">
{/* Day headers */}
<div class="grid grid-cols-14 border-b border-[var(--border)]">
<For each={visibleDays()}>
{day => {
const isToday = isSameDay(day, new Date());
return (
<div class={`px-2 py-2 text-center border-r border-[var(--border)] last:border-r-0 ${isToday ? "bg-[var(--accent-muted)]" : ""}`}>
<div class="text-xs text-[var(--text-muted)]">{format(day, "EEE")}</div>
<div class={`text-sm font-medium ${isToday ? "text-[var(--accent)]" : ""}`}>
{format(day, "d")}
</div>
</div>
);
}}
</For>
</div>
{/* Timeline content */}
<div class="grid grid-cols-14 min-h-[400px]">
<For each={visibleDays()}>
{day => {
const items = itemsForDay(day);
const isToday = isSameDay(day, new Date());
return (
<div class={`border-r border-[var(--border)] last:border-r-0 p-1 ${isToday ? "bg-[var(--accent-muted)]/30" : ""}`}>
<div class="space-y-1">
<For each={items.slice(0, 6)}>
{item => {
const color = getColorStyle(item.color);
return (
<div
class="rounded px-1.5 py-0.5 text-xs truncate cursor-pointer hover:opacity-80"
style={{ background: color.bg, color: color.text }}
title={item.title}
>
<div class="flex items-center gap-1">
<Show when={item.kind === "task"}>
{item.status === "done" ? (
<CheckCircle class="w-3 h-3 flex-shrink-0" />
) : (
<Circle class="w-3 h-3 flex-shrink-0" />
)}
</Show>
<span class="truncate">{item.title}</span>
</div>
</div>
);
}}
</For>
<Show when={items.length > 6}>
<div class="text-xs text-[var(--text-muted)] px-1.5">
+{items.length - 6} more
</div>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
{/* Legend */}
<div class="flex items-center gap-4 text-sm text-[var(--text-muted)]">
<div class="flex items-center gap-1.5">
<Circle class="w-4 h-4" />
<span>Task</span>
</div>
<div class="flex items-center gap-1.5">
<CheckCircle class="w-4 h-4 text-green-500" />
<span>Completed</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-4 h-4 rounded bg-[var(--accent-muted)]" />
<span>Today</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { isToday, parseISO } from "date-fns";
import { useApp } from "~/lib/app-context";
import { classifyActivity, getActivityTypeLabel, listActivityTypeOptions, type ActivityType } from "~/lib/activity";
import { formatPrettyDate, getColorStyle } from "~/lib/utils";
export default function TodayRoute() {
const app = useApp();
const [capture, setCapture] = createSignal("");
const [activityType, setActivityType] = createSignal<ActivityType>("all");
const [activityQuery, setActivityQuery] = createSignal("");
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const dueToday = createMemo(() =>
app.state.tasks.filter(task => task.workspaceSlug === workspaceSlug() && task.dueAt && isToday(parseISO(task.dueAt))),
);
const agenda = createMemo(() => {
const taskItems = app.state.tasks
.filter(task => task.workspaceSlug === workspaceSlug() && task.scheduledStart)
.map(task => ({ kind: "task" as const, title: task.title, startsAt: task.scheduledStart!, color: task.color }));
const eventItems = app.state.events
.filter(event => event.workspaceSlug === workspaceSlug())
.map(event => ({ kind: "event" as const, title: event.title, startsAt: event.startsAt, color: event.color }));
return [...taskItems, ...eventItems]
.filter(item => isToday(parseISO(item.startsAt)))
.sort((left, right) => left.startsAt.localeCompare(right.startsAt));
});
const focusMinutes = createMemo(() =>
app.state.focusSessions.reduce((sum, session) => sum + Math.round(session.durationSeconds / 60), 0),
);
const recentActivity = createMemo(() => {
const selectedType = activityType();
const query = activityQuery().trim().toLowerCase();
return app.state.activities
.filter(entry => entry.workspaceSlug === workspaceSlug())
.filter(entry => (selectedType === "all" ? true : classifyActivity(entry) === selectedType))
.filter(entry => {
if (!query) {
return true;
}
return entry.title.toLowerCase().includes(query) || entry.detail.toLowerCase().includes(query);
})
.slice(0, 8);
});
const quickTask = () => {
if (!capture().trim()) {
return;
}
app.createTask({
workspaceSlug: workspaceSlug(),
boardGroupId: "group-inbox",
title: capture(),
color: "slate"
});
setCapture("");
};
return (
<div class="space-y-8">
{/* Page header */}
<div class="fade-in">
<h1 class="page-title">Today</h1>
<p class="mt-2 text-base text-[var(--text-muted)]">Plan the next useful move</p>
</div>
{/* Stats row */}
<div class="grid gap-4 sm:grid-cols-3">
<div class="stagger-item rounded-xl border border-[var(--border)] bg-gradient-to-br from-[var(--surface)] to-[var(--surface-tinted)] p-6 shadow-sm hover-lift">
<p class="section-title text-[var(--text-muted)]">Due today</p>
<p class="mt-3 text-3xl font-bold" style="font-family: var(--font-serif);">{dueToday().length}</p>
</div>
<div class="stagger-item rounded-xl border border-[var(--border)] bg-gradient-to-br from-[var(--surface)] to-[var(--surface-tinted)] p-6 shadow-sm hover-lift">
<p class="section-title text-[var(--text-muted)]">Agenda items</p>
<p class="mt-3 text-3xl font-bold" style="font-family: var(--font-serif);">{agenda().length}</p>
</div>
<div class="stagger-item rounded-xl border border-[var(--border)] bg-gradient-to-br from-[var(--surface)] to-[var(--surface-tinted)] p-6 shadow-sm hover-lift">
<p class="section-title text-[var(--text-muted)]">Focus minutes</p>
<p class="mt-3 text-3xl font-bold" style="font-family: var(--font-serif);">{focusMinutes()}</p>
</div>
</div>
{/* Quick capture */}
<div class="rounded-xl border border-[var(--border-accent)] bg-[var(--bg-accent)] p-6 shadow-md">
<div class="flex flex-col gap-4 sm:flex-row">
<input
class="input-base flex-1"
placeholder="Quick capture a task..."
value={capture()}
onInput={event => setCapture(event.currentTarget.value)}
onKeyDown={event => event.key === "Enter" && quickTask()}
/>
<button class="button-primary px-6 py-3 text-base font-semibold whitespace-nowrap" onClick={quickTask}>
Add to inbox
</button>
</div>
</div>
{/* Main content grid */}
<div class="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
{/* Agenda */}
<div class="rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-md overflow-hidden">
<div class="border-b border-[var(--border)] px-6 py-4 bg-[var(--bg-subtle)]">
<h2 class="text-base font-semibold" style="font-family: var(--font-serif);">Agenda</h2>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={agenda().length === 0}>
<div class="px-6 py-12 text-center text-sm text-[var(--text-muted)]">
No scheduled items for today
</div>
</Show>
<For each={agenda()}>
{item => {
const color = getColorStyle(item.color);
return (
<div class="flex items-center gap-4 px-6 py-4 transition-all hover:bg-[var(--bg-subtle)]">
<span
class="shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold"
style={{ background: color.bg, color: color.text }}
>
{item.kind}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-base font-medium">{item.title}</p>
<p class="text-sm text-[var(--text-muted)] mt-0.5">{formatPrettyDate(item.startsAt)}</p>
</div>
</div>
);
}}
</For>
</div>
</div>
{/* Right column */}
<div class="space-y-8">
{/* Due soon */}
<div class="rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-md overflow-hidden">
<div class="border-b border-[var(--border)] px-6 py-4 bg-[var(--bg-subtle)]">
<h2 class="text-base font-semibold" style="font-family: var(--font-serif);">Due soon</h2>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={dueToday().length === 0}>
<div class="px-6 py-10 text-center text-sm text-[var(--text-muted)]">
Nothing due today
</div>
</Show>
<For each={dueToday()}>
{task => {
const color = getColorStyle(task.color);
return (
<div class="px-6 py-4 transition-all hover:bg-[var(--bg-subtle)]">
<div class="flex items-center justify-between gap-3 mb-2">
<p class="truncate text-base font-medium">{task.title}</p>
<span
class="shrink-0 rounded-lg px-3 py-1 text-xs font-semibold"
style={{ background: color.bg, color: color.text }}
>
{task.status.replace("_", " ")}
</span>
</div>
<Show when={task.description}>
<p class="text-sm text-[var(--text-muted)] line-clamp-2">{task.description}</p>
</Show>
</div>
);
}}
</For>
</div>
</div>
{/* Recent activity */}
<div class="rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-md overflow-hidden">
<div class="border-b border-[var(--border)] px-6 py-4 bg-[var(--bg-subtle)]">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-base font-semibold" style="font-family: var(--font-serif);">Recent activity</h2>
<div class="flex gap-2">
<input
class="input-base h-9 w-full text-sm sm:w-36"
placeholder="Search..."
value={activityQuery()}
onInput={event => setActivityQuery(event.currentTarget.value)}
/>
<select
class="input-base h-9 w-24 text-sm"
value={activityType()}
onInput={event => setActivityType(event.currentTarget.value as ActivityType)}
>
<For each={listActivityTypeOptions()}>
{option => <option value={option.value}>{option.label}</option>}
</For>
</select>
</div>
</div>
</div>
<div class="divide-y divide-[var(--border)]">
<Show when={recentActivity().length === 0}>
<div class="px-6 py-10 text-center text-sm text-[var(--text-muted)]">
No activity matches this filter
</div>
</Show>
<For each={recentActivity()}>
{entry => {
const entryType = classifyActivity(entry);
return (
<div class="px-6 py-4 transition-all hover:bg-[var(--bg-subtle)]">
<div class="flex items-center justify-between gap-3 mb-1">
<p class="truncate text-base font-medium">{entry.title}</p>
<span class="shrink-0 text-xs font-semibold text-[var(--text-muted)]">
{getActivityTypeLabel(entryType)}
</span>
</div>
<p class="text-sm text-[var(--text-muted)] line-clamp-1 mb-1">{entry.detail}</p>
<p class="text-xs text-[var(--text-subtle)]">{formatPrettyDate(entry.createdAt)}</p>
</div>
);
}}
</For>
</div>
</div>
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Navigate } from "@solidjs/router";
export default function IndexPage() {
return <Navigate href="/login" />;
}
+274
View File
@@ -0,0 +1,274 @@
import { useNavigate } from "@solidjs/router";
import { ArrowRight, Mail } from "lucide-solid";
import { createSignal, For, Show } from "solid-js";
import { authBaseUrl, authClient } from "~/lib/auth-client";
import { useApp } from "~/lib/app-context";
type AuthMode = "sign-in" | "sign-up" | "magic-link";
interface DevMailboxEntry {
id: string;
kind: "magic-link";
email: string;
subject: string;
url: string;
createdAt: string;
}
const authModes: Array<{ id: AuthMode; label: string }> = [
{ id: "sign-in", label: "Sign in" },
{ id: "sign-up", label: "Create account" },
{ id: "magic-link", label: "Magic link" }
];
export default function LoginPage() {
const app = useApp();
const navigate = useNavigate();
const devMailboxEnabled = import.meta.env.VITE_DEV_MAILBOX_ENABLED !== "false";
const [mode, setMode] = createSignal<AuthMode>("sign-in");
const [name, setName] = createSignal("Taylor");
const [email, setEmail] = createSignal("taylor@productier.app");
const [password, setPassword] = createSignal("password1234");
const [submitting, setSubmitting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [notice, setNotice] = createSignal<string | null>(null);
const [devMailbox, setDevMailbox] = createSignal<DevMailboxEntry[]>([]);
const callbackUrl = () => {
const origin = typeof window !== "undefined" ? window.location.origin : import.meta.env.VITE_FRONTEND_URL || "http://localhost:5173";
return `${origin}/app/personal/today`;
};
const refreshDevMailbox = async () => {
if (!devMailboxEnabled) {
return;
}
const response = await fetch(`${authBaseUrl}/api/dev-mailbox?email=${encodeURIComponent(email())}`);
const payload = (await response.json()) as { data?: DevMailboxEntry[] };
setDevMailbox(payload.data ?? []);
};
const openWorkspace = async () => {
await app.hydrateFromApi().catch(() => undefined);
navigate("/app/personal/today");
};
const submit = async (event: SubmitEvent) => {
event.preventDefault();
setSubmitting(true);
setError(null);
setNotice(null);
try {
if (mode() === "sign-in") {
const result = await authClient.signIn.email({
email: email(),
password: password(),
rememberMe: true
});
if (result.error) {
setError(result.error.message || "Could not sign in with that email and password.");
return;
}
await openWorkspace();
return;
}
if (mode() === "sign-up") {
const result = await authClient.signUp.email({
name: name(),
email: email(),
password: password(),
callbackURL: callbackUrl()
});
if (result.error) {
setError(result.error.message || "Could not create the account.");
return;
}
await openWorkspace();
return;
}
const result = await authClient.signIn.magicLink({
email: email(),
name: name().trim() || undefined,
callbackURL: callbackUrl()
});
if (result.error) {
setError(result.error.message || "Could not send the magic link.");
return;
}
if (devMailboxEnabled) {
await refreshDevMailbox();
setNotice("Magic link sent. Check the dev mailbox below.");
} else {
setNotice("Magic link sent. Check your inbox for the sign-in link.");
}
} finally {
setSubmitting(false);
}
};
return (
<main class="flex min-h-screen items-center justify-center p-5" style="position: relative; z-index: 1;">
<div class="w-full max-w-md">
{/* Logo */}
<div class="mb-10 text-center fade-in">
<div class="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--accent)] to-[var(--secondary)] text-white text-2xl font-bold shadow-xl">
P
</div>
<h1 class="text-2xl font-semibold mb-2" style="font-family: var(--font-serif);">Productier</h1>
<p class="text-sm text-[var(--text-muted)]">Calm productivity workspace</p>
</div>
{/* Auth card */}
<div class="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-8 shadow-xl scale-in">
{/* Mode tabs */}
<div class="mb-8 flex rounded-xl bg-[var(--bg-subtle)] p-1.5">
<For each={authModes}>
{item => (
<button
class="flex-1 rounded-lg px-4 py-3 text-sm font-semibold transition-all"
classList={{
"bg-[var(--surface)] text-[var(--text)] shadow-md": mode() === item.id,
"text-[var(--text-muted)] hover:text-[var(--text)]": mode() !== item.id
}}
onClick={() => {
setMode(item.id);
setError(null);
setNotice(null);
}}
>
{item.label}
</button>
)}
</For>
</div>
{/* Form */}
<form class="space-y-5" onSubmit={event => void submit(event)}>
<Show when={mode() !== "sign-in"}>
<div>
<label class="mb-2 block text-sm font-semibold">Name</label>
<input
class="input-base"
placeholder="Your name"
value={name()}
onInput={event => setName(event.currentTarget.value)}
/>
</div>
</Show>
<div>
<label class="mb-2 block text-sm font-semibold">Email</label>
<input
class="input-base"
type="email"
placeholder="you@example.com"
value={email()}
onInput={event => setEmail(event.currentTarget.value)}
/>
</div>
<Show when={mode() !== "magic-link"}>
<div>
<label class="mb-2 block text-sm font-semibold">Password</label>
<input
class="input-base"
type="password"
placeholder="••••••••"
value={password()}
onInput={event => setPassword(event.currentTarget.value)}
/>
<p class="mt-2 text-xs text-[var(--text-muted)]">
Minimum 8 characters. Dev default: password1234
</p>
</div>
</Show>
<Show when={error()}>
<div class="rounded-xl bg-[var(--error-muted)] px-5 py-4 text-sm text-[var(--error)] border border-[var(--error)] border-opacity-20">
{error()}
</div>
</Show>
<Show when={notice()}>
<div class="rounded-xl bg-[var(--success-muted)] px-5 py-4 text-sm text-[var(--success)] border border-[var(--success)] border-opacity-20">
{notice()}
</div>
</Show>
<button
class="button-primary flex w-full items-center justify-center gap-2 px-5 py-3.5 text-base font-semibold"
disabled={submitting()}
type="submit"
>
<Show when={!submitting()} fallback="Working...">
{mode() === "sign-in" ? "Sign in" : mode() === "sign-up" ? "Create account" : "Send magic link"}
<ArrowRight size={18} />
</Show>
</button>
</form>
{/* Dev mailbox */}
<Show when={devMailboxEnabled}>
<div class="mt-8 border-t border-[var(--border)] pt-8">
<div class="mb-4 flex items-center gap-2">
<Mail size={16} class="text-[var(--text-muted)]" />
<span class="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Dev Mailbox</span>
</div>
<button
class="button-secondary mb-4 w-full px-4 py-2.5 text-sm"
onClick={() => void refreshDevMailbox()}
type="button"
>
Refresh messages
</button>
<div class="space-y-2">
<Show
when={devMailbox().length}
fallback={
<p class="text-sm text-[var(--text-muted)] text-center py-4">
No emails yet. Send a magic link to populate.
</p>
}
>
<For each={devMailbox()}>
{entry => (
<a
class="block rounded-xl bg-[var(--bg-subtle)] p-4 transition-all hover:bg-[var(--bg-muted)] hover:shadow-sm"
href={entry.url}
>
<div class="flex items-center justify-between gap-2 mb-2">
<span class="text-sm font-semibold">{entry.subject}</span>
<span class="text-xs text-[var(--text-muted)]">
{new Date(entry.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<p class="truncate text-xs text-[var(--text-muted)] font-mono">{entry.url}</p>
</a>
)}
</For>
</Show>
</div>
</div>
</Show>
</div>
{/* Footer */}
<p class="mt-8 text-center text-xs text-[var(--text-muted)]">
SolidJS + Vite + Go + PostgreSQL
</p>
</div>
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"noEmit": true,
"strict": true,
"types": ["vite/client"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
plugins: [solid(), tailwindcss()],
resolve: {
alias: {
"~": resolve(__dirname, "./src")
}
},
server: {
port: 5173,
proxy: {
"/api/auth": {
target: "http://localhost:43001",
changeOrigin: true
}
}
},
build: {
target: "esnext"
}
});