mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-03 20:13:01 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
!.env.example
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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("/"));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
@@ -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);
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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()]
|
||||
});
|
||||
@@ -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>');
|
||||
}
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 doesn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Navigate } from "@solidjs/router";
|
||||
|
||||
export default function IndexPage() {
|
||||
return <Navigate href="/login" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user