ALL 0.1.0 Code

This commit is contained in:
2026-02-28 04:21:27 +00:00
commit 7958510989
76 changed files with 17135 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.svelte-kit
build
data
server/bin
.git
*.md
.env

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
bun.lock
data/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}

471
BUILD.md Normal file
View File

@@ -0,0 +1,471 @@
# FPMB — Build & Architecture Reference
This document is the authoritative reference for agentic coding sessions. Read it fully before making changes.
---
## Quick-start commands
```bash
# Frontend dev server
bun run dev
# Frontend production build (source of truth for TS errors)
bun run build
# Backend run (from server/)
go run ./cmd/api/main.go
# Backend compile check (source of truth for Go errors)
go build ./...
```
- Go binary: `/usr/bin/go` (1.22.2)
- Package manager: `bun` (not npm/pnpm/yarn)
- Go module path: `github.com/fpmb/server`
- Backend must be run from `server/` — relative paths like `../data/` resolve from there
---
## Repository layout
```
openboard/
├── src/ SvelteKit frontend (static SPA adapter)
│ ├── lib/
│ │ ├── api/
│ │ │ ├── client.ts apiFetch, apiFetchFormData, token management
│ │ │ └── index.ts all typed API methods grouped by resource
│ │ ├── components/
│ │ │ ├── Markdown/ Markdown.svelte — renders marked + DOMPurify
│ │ │ └── Modal/ Modal.svelte — generic overlay
│ │ ├── stores/
│ │ │ └── auth.svelte.ts authStore singleton (init, login, register, logout, setUser)
│ │ ├── types/
│ │ │ └── api.ts TypeScript interfaces mirroring Go models
│ │ └── utils/
│ │ └── fileRefs.ts resolveFileRefs() — converts $file:<name> refs in Markdown
│ └── routes/
│ ├── (auth)/ login, register pages (no auth guard)
│ └── (app)/
│ ├── +layout.svelte auth guard, top navbar (avatar, logout button)
│ ├── +page.svelte dashboard
│ ├── board/[id]/ Kanban board
│ ├── calendar/ month/week calendar
│ ├── notifications/ notification inbox
│ ├── projects/ project list + project settings
│ ├── settings/user/ user profile + avatar upload + password change
│ ├── team/[id]/ team overview + team settings (avatar/banner upload)
│ └── whiteboard/[id]/ canvas whiteboard
├── server/
│ ├── cmd/api/main.go Fiber app bootstrap, all route registrations
│ └── internal/
│ ├── database/db.go MongoDB connection, GetCollection helper
│ ├── handlers/ one file per resource group (auth, teams, projects, …)
│ ├── middleware/auth.go JWT Protected() middleware
│ ├── models/models.go all MongoDB document structs (source of truth for field names)
│ ├── routes/ (unused legacy dir — ignore)
│ └── utils/ shared Go utilities
├── static/ fonts, favicon
├── data/ runtime file storage (gitignored)
│ ├── teams/<teamID>/avatar.<ext>
│ ├── teams/<teamID>/banner.<ext>
│ ├── users/<userID>/avatar.<ext>
│ └── projects/<projectID>/files/…
├── build/ SvelteKit production output (served by Go)
├── package.json
├── svelte.config.js
├── vite.config.ts
└── tsconfig.json
```
---
## Backend (Go + GoFiber v2)
### Route registration — `server/cmd/api/main.go`
All routes are registered here. Add new routes to the appropriate group. Every group except `/auth` is wrapped in `middleware.Protected()`.
```
/api/health GET — liveness check
/api/auth/register POST
/api/auth/login POST
/api/auth/refresh POST
/api/auth/logout POST (Protected)
/api/users/me GET PUT
/api/users/me/password PUT
/api/users/me/avatar POST GET (multipart upload / serve)
/api/users/me/files GET
/api/users/me/files/folder POST
/api/users/me/files/upload POST
/api/users/search GET ?q=
/api/teams GET POST
/api/teams/:teamId GET PUT DELETE
/api/teams/:teamId/members GET
/api/teams/:teamId/members/invite POST
/api/teams/:teamId/members/:userId PUT DELETE
/api/teams/:teamId/projects GET POST
/api/teams/:teamId/events GET POST
/api/teams/:teamId/docs GET POST
/api/teams/:teamId/files GET
/api/teams/:teamId/files/folder POST
/api/teams/:teamId/files/upload POST
/api/teams/:teamId/avatar POST GET
/api/teams/:teamId/banner POST GET
/api/projects GET POST
/api/projects/:projectId GET PUT DELETE
/api/projects/:projectId/archive PUT
/api/projects/:projectId/members GET POST
/api/projects/:projectId/members/:userId PUT DELETE
/api/projects/:projectId/board GET
/api/projects/:projectId/columns POST
/api/projects/:projectId/columns/:columnId PUT DELETE
/api/projects/:projectId/columns/:columnId/position PUT
/api/projects/:projectId/columns/:columnId/cards POST
/api/projects/:projectId/events GET POST
/api/projects/:projectId/files GET
/api/projects/:projectId/files/folder POST
/api/projects/:projectId/files/upload POST
/api/projects/:projectId/webhooks GET POST
/api/projects/:projectId/whiteboard GET PUT
/api/cards/:cardId PUT DELETE
/api/cards/:cardId/move PUT
/api/events/:eventId PUT DELETE
/api/notifications GET
/api/notifications/read-all PUT
/api/notifications/:notifId/read PUT
/api/notifications/:notifId DELETE
/api/docs/:docId GET PUT DELETE
/api/files/:fileId/download GET
/api/files/:fileId DELETE
/api/webhooks/:webhookId PUT DELETE
/api/webhooks/:webhookId/toggle PUT
```
### MongoDB models — `server/internal/models/models.go`
This file is the single source of truth for all field names and types. Always check here before referencing a field.
| Struct | Collection | Key fields |
|---|---|---|
| `User` | `users` | `_id`, `name`, `email`, `password_hash` (json:`-`), `avatar_url` |
| `Team` | `teams` | `_id`, `name`, `workspace_id`, `avatar_url`, `banner_url`, `created_by` |
| `TeamMember` | `team_members` | `team_id`, `user_id`, `role_flags`, `invited_by` |
| `Project` | `projects` | `_id`, `team_id`, `name`, `description`, `visibility`, `is_public`, `is_archived`, `created_by` |
| `ProjectMember` | `project_members` | `project_id`, `user_id`, `role_flags` |
| `BoardColumn` | `columns` | `project_id`, `title`, `position` |
| `Card` | `cards` | `column_id`, `project_id`, `title`, `description`, `priority`, `color`, `due_date`, `assignees []string`, `subtasks []Subtask`, `position` |
| `Subtask` | (embedded) | `id int`, `text`, `done` |
| `Event` | `events` | `title`, `date`, `time`, `color`, `description`, `scope`, `scope_id` |
| `Notification` | `notifications` | `user_id`, `type`, `message`, `project_id`, `card_id`, `read` |
| `Doc` | `docs` | `team_id`, `title`, `content`, `created_by` |
| `File` | `files` | `project_id`, `team_id`, `user_id`, `name`, `type`, `size_bytes`, `parent_id`, `storage_url` |
| `Webhook` | `webhooks` | `project_id`, `name`, `type`, `url`, `secret_hash` (json:`-`), `status`, `last_triggered` |
| `Whiteboard` | `whiteboards` | `project_id`, `data` |
### RBAC
Roles are **hierarchical integers**, not bitflags. Use `>=` comparisons.
```
Viewer = 1
Editor = 2
Admin = 4
Owner = 8
```
Example: `member.RoleFlags >= 2` means Editor or above.
### Image upload/serve pattern (handler)
Reference implementation: `server/internal/handlers/teams.go` (UploadTeamAvatar / ServeTeamAvatar).
```go
// Validate extension
allowedImageExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
// Build storage path
dir := fmt.Sprintf("../data/<resource>/%s", id.Hex())
os.MkdirAll(dir, 0755)
// Delete old file (glob by base name, any extension)
old, _ := filepath.Glob(filepath.Join(dir, "avatar.*"))
for _, f := range old { os.Remove(f) }
// Save new file
c.SaveFile(fh, filepath.Join(dir, "avatar"+ext))
// Update DB with static URL string
database.GetCollection("<coll>").UpdateOne(ctx, bson.M{"_id": id},
bson.M{"$set": bson.M{"avatar_url": "/api/<resource>/avatar", "updated_at": time.Now()}})
// Serve
matches, _ := filepath.Glob(filepath.Join(dir, "avatar.*"))
return c.SendFile(matches[0])
```
Key points:
- `allowedImageExts` is declared once in `teams.go` and reused across the `handlers` package (same package, no redeclaration needed in `users.go`)
- Avatar URL stored in DB is a **static string** (e.g. `/api/users/me/avatar`), not per-extension — the serve endpoint globs at runtime
- Max upload size: GoFiber default (4MB). Adjust `fiber.Config{BodyLimit}` in `main.go` if needed
### Background jobs
`startDueDateReminder()` in `main.go` runs every hour and creates `due_soon` notifications for cards due within 1 or 3 days. It deduplicates within a 24-hour window.
---
## Frontend (SvelteKit + Svelte 5 Runes)
### Rules — non-negotiable
- **Svelte 5 Runes only**: `$state`, `$derived`, `$props`, `$bindable`. `onMount` is also acceptable.
- **No legacy Svelte**: no `export let`, no reactive `$:`, no `writable` stores.
- `$state` may only be used inside `.svelte`, `.svelte.ts`, or `.svelte.js` files — never in plain `.ts` modules.
- No code comments anywhere.
- No extra files or folders.
- `$page.params.id` must always be written as `$page.params.id ?? ''`.
### LSP / type errors
The LSP cache is often stale. **Do not** treat LSP errors as real until confirmed by `bun run build`. Pre-existing LSP errors on `$page`, `Project.visibility`, `Project.is_public`, etc. are known stale artefacts.
### API client — `src/lib/api/client.ts`
| Export | Purpose |
|---|---|
| `apiFetch<T>(path, options?)` | JSON fetch, auto-refreshes token on 401, throws on error |
| `apiFetchFormData<T>(path, formData)` | Multipart POST (method hardcoded to POST), same token/retry logic |
| `getAccessToken()` | Returns current in-memory access token |
| `setAccessToken(token)` | Updates in-memory token and localStorage |
Tokens:
- `access_token` — localStorage + in-memory, 15-minute JWT
- `refresh_token` — localStorage only, 7-day JWT
- `user_id` — localStorage only (used for convenience reads)
### API methods — `src/lib/api/index.ts`
All API calls are grouped by resource: `auth`, `users`, `teams`, `projects`, `board`, `cards`, `events`, `notifications`, `docs`, `files`, `webhooks`. Import the group you need:
```ts
import { users, teams, projects, board, cards } from '$lib/api';
```
### Auth store — `src/lib/stores/auth.svelte.ts`
```ts
authStore.user // User | null (reactive)
authStore.loading // boolean (reactive)
authStore.init() // call once in root layout onMount
authStore.login(email, password)
authStore.register(name, email, password)
authStore.logout() // calls API, clears tokens, nulls user
authStore.setUser(u) // update user after profile/avatar changes
```
### TypeScript interfaces — `src/lib/types/api.ts`
Mirrors Go models exactly. Always import from here; never inline interfaces.
Key interfaces: `User`, `Team`, `TeamMember`, `Project`, `ProjectMember`, `Card`, `Subtask`, `Column`, `BoardData`, `Event`, `Notification`, `Doc`, `FileItem`, `Webhook`, `Whiteboard`, `AuthResponse`.
### File ref resolution — `src/lib/utils/fileRefs.ts`
`resolveFileRefs(text, files)` replaces `$file:<name>` tokens in Markdown with links. Unmatched refs render as `` `unknown file: <name>` ``.
### Tailwind notes
- Use Tailwind v4 utility classes only.
- `ring-neutral-750` does not exist — use `ring-neutral-800`.
- Never combine `inline-block` with `flex` on the same element — `inline-block` wins and kills flex behaviour. Use `flex` alone.
---
## Common patterns
### Adding a new API route (end-to-end checklist)
1. Add handler function in the appropriate `server/internal/handlers/*.go` file.
2. Register the route in `server/cmd/api/main.go` under the right group.
3. Add the typed method to `src/lib/api/index.ts`.
4. If a new response shape is needed, add an interface to `src/lib/types/api.ts`.
5. Call from a `.svelte` page; use `apiFetchFormData` for multipart, `apiFetch` for everything else.
6. Verify: `go build ./...` from `server/` and `bun run build` from root.
### Archived project read-only enforcement
`src/routes/(app)/board/[id]/+page.svelte`:
- `isArchived` state derived from `project.is_archived` on mount.
- Yellow banner shown when archived.
- Cards: `draggable={!isArchived}`, click/keydown handlers suppressed, edit button hidden.
- Add Card and Add Column buttons hidden when archived.
- `handleDrop` returns early when `isArchived`.
### User / team avatar upload (frontend)
```ts
const updated = await users.uploadAvatar(file); // returns updated User
authStore.setUser(updated);
const updatedTeam = await teams.uploadAvatar(teamId, file); // returns updated Team
```
Display: show `<img src={user.avatar_url}>` if `avatar_url` is set, otherwise show the user's initial letter in a coloured circle.
---
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `PORT` | `8080` | Server listen port |
| `MONGO_URI` | `mongodb://localhost:27017` | MongoDB connection string |
| `MONGO_DB_NAME` | `fpmb` | MongoDB database name |
| `JWT_SECRET` | — | Required in production |
`.env` is loaded automatically via `godotenv` in `main.go`. System env vars are used as fallback.
---
## Data directory layout
Runtime files are stored under `data/` relative to the repository root. The Go server runs from `server/`, so it accesses `../data/`.
```
data/
├── teams/<teamID>/
│ ├── avatar.<ext>
│ └── banner.<ext>
├── users/<userID>/
│ └── avatar.<ext>
└── projects/<projectID>/files/…
```
`data/` is gitignored.
Goal
Build out and improve the FPMB (Free Project Management Boards) self-hosted project management app at /home/coder/openboard. The current session focused on: user avatar upload, logout button, archived project read-only mode, task card UI fixes, and $file: unknown file handling. The final task (still in progress) is updating README.md and creating BUILD.md with full coding/architecture details for future agent sessions.
---
Instructions
- Project is at /home/coder/openboard
- No code comments
- No extra files or folders
- Frontend: Svelte 5 Runes only ($state, $derived, $props, $bindable) — onMount is acceptable; no legacy Svelte patterns
- Backend: Go, module path github.com/fpmb/server
- Use bun (not npm): bun run build, bun run dev
- Go is at /usr/bin/go (1.22.2); run from server/ via go run ./cmd/api/main.go
- LSP has a stale cache — use bun run build / go build ./... as the source of truth for real errors; ignore LSP errors that were pre-existing
- $page.params.id must use ?? ''
- RBAC: Viewer=1, Editor=2, Admin=4, Owner=8 — roles are hierarchical (>= not bitwise)
- Server runs from server/ so ../data/ resolves to /home/coder/openboard/data/
- File storage: ../data/teams/<teamID>/avatar.<ext>, ../data/users/<userID>/avatar.<ext>
- Use apiFetchFormData in frontend for multipart uploads
- $state may only be used inside .svelte or .svelte.ts/.svelte.js files, not plain .ts modules
---
Discoveries
Architecture
- allowedImageExts map is defined in server/internal/handlers/teams.go — shared across the handlers package, so users.go reuses it directly (same package, no redeclaration)
- authStore (src/lib/stores/auth.svelte.ts) already has a logout() method that calls the API, clears tokens, and nulls the user — no need to duplicate logic
- apiFetchFormData always uses POST method (hardcoded in client.ts)
- LSP errors on board page ($page, Project.visibility, Project.is_public, etc.) are all pre-existing stale cache issues — builds pass cleanly
- ring-neutral-750 is not a valid Tailwind class (causes white ring fallback); use ring-neutral-800 for card backgrounds
- inline-block + flex conflict — inline-block wins and kills flex centering; use flex alone
- $file:<name> refs are resolved in src/lib/utils/fileRefs.ts via resolveFileRefs() — unmatched refs previously returned raw `$file:name` syntax
- Board page is_archived enforcement: drag-drop guarded in handleDrop, template uses {#if !isArchived} to hide Add Card / Add Column, draggable={!isArchived} on cards
Key patterns
- Team/user image upload: validate ext with allowedImageExts, glob-delete old file, SaveFile, UpdateOne with URL, return updated document
- Serve image: glob-find by extension, SendFile
- Avatar URL stored as /api/users/me/avatar (static string, not per-extension) — the serve endpoint finds the file at runtime
---
Accomplished
✅ Completed this session
1. User avatar upload — Full end-to-end:
- server/internal/handlers/users.go: Added UploadUserAvatar and ServeUserAvatar handlers
- server/cmd/api/main.go: Registered POST /users/me/avatar and GET /users/me/avatar
- src/lib/api/index.ts: Added users.uploadAvatar(file)
- src/routes/(app)/settings/user/+page.svelte: Added avatar display (<img> if set, else initial letter), file input upload button, success/error feedback, calls authStore.setUser(updated)
2. Logout button — Added to top navbar in src/routes/(app)/+layout.svelte:
- Arrow-out icon button to the right of the user avatar (desktop only, hidden md:flex)
- Calls authStore.logout() then goto('/login')
3. Archived project read-only mode — src/routes/(app)/board/[id]/+page.svelte:
- Added isArchived state, populated from project.is_archived on mount
- Yellow archived banner shown when isArchived is true
- Cards: draggable={!isArchived}, click/keydown suppressed, edit ... button hidden
- "Add Card" button hidden per column when archived
- "Add Column" button hidden when archived
- handleDrop returns early when isArchived
4. Task card assignee ring fix — ring-neutral-750 → ring-neutral-800, inline-block removed (was conflicting with flex centering)
5. $file: unknown file fallback — src/lib/utils/fileRefs.ts: unmatched refs now render as `unknown file: <name>`
🔄 In Progress — Documentation update
- README.md — needs updating to reflect: user avatar upload, team avatar/banner, logout button, archived read-only mode, new API routes
- BUILD.md — needs to be created as a comprehensive reference for future agentic coding sessions, covering all architecture, conventions, patterns, file layout, API routes, models, and coding rules
---
Relevant files / directories
Backend (Go)
server/
├── cmd/api/main.go routes registration (recently: user avatar routes added)
└── internal/
├── handlers/
│ ├── auth.go
│ ├── teams.go reference: allowedImageExts, uploadTeamImage/serveTeamImage pattern
│ ├── users.go recently added: UploadUserAvatar, ServeUserAvatar
│ ├── projects.go
│ ├── board.go
│ ├── cards.go
│ ├── files.go
│ ├── notifications.go
│ ├── docs.go
│ ├── events.go
│ ├── webhooks.go
│ └── whiteboard.go
├── middleware/
│ └── auth.go
├── models/
│ └── models.go source of truth for all field names/types
└── database/
└── db.go
Frontend
src/
├── lib/
│ ├── api/
│ │ ├── client.ts apiFetch, apiFetchFormData, token management
│ │ └── index.ts all API methods (recently: users.uploadAvatar added)
│ ├── components/
│ │ ├── Markdown/Markdown.svelte
│ │ └── Modal/Modal.svelte
│ ├── stores/
│ │ └── auth.svelte.ts authStore: init, login, register, logout, setUser
│ ├── types/
│ │ └── api.ts all TypeScript interfaces
│ └── utils/
│ └── fileRefs.ts resolveFileRefs (recently: unknown file fallback fixed)
└── routes/
└── (app)/
├── +layout.svelte navbar, auth guard (recently: logout button added)
├── board/[id]/+page.svelte kanban board (recently: archived read-only mode)
├── settings/user/+page.svelte user settings (recently: avatar upload UI)
├── team/[id]/
│ ├── +page.svelte team overview (shows avatar/banner)
│ └── settings/+page.svelte team settings (avatar/banner upload)
└── projects/
├── +page.svelte project list (shows is_archived badge)
└── [id]/settings/+page.svelte project settings (archive/delete)
Docs (in progress)
/home/coder/openboard/README.md needs update
/home/coder/openboard/BUILD.md needs to be created
Data directory (runtime)
/home/coder/openboard/data/
├── teams/<teamID>/avatar.<ext>
├── teams/<teamID>/banner.<ext>
└── users/<userID>/avatar.<ext>

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:22-alpine AS frontend
WORKDIR /app
COPY package.json bun.lockb* ./
RUN npm install
COPY . .
RUN npm run build
FROM golang:1.24-alpine AS backend
WORKDIR /app/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ .
RUN CGO_ENABLED=0 GOOS=linux go build -o /fpmb-server ./cmd/api/main.go
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app/server
COPY --from=backend /fpmb-server ./fpmb-server
COPY --from=frontend /app/build ../build
COPY --from=frontend /app/static ../static
RUN mkdir -p ../data
EXPOSE 8080
CMD ["./fpmb-server"]

197
README.md Normal file
View File

@@ -0,0 +1,197 @@
# FPMB — Free Project Management Boards
FPMB is a self-hosted, open-source project management platform. It provides Kanban boards, task tracking, team collaboration, a canvas whiteboard, a knowledge base, a calendar, file management, webhook integrations, API key management, and role-based access control — all in one application.
## Features
- **Kanban Boards** — drag-and-drop cards with priority, color labels, due dates, assignees, subtasks, and Markdown descriptions
- **Personal & Team Projects** — create projects scoped to a team or privately for yourself
- **Whiteboard** — full-screen canvas with pen, rectangle, circle, and eraser tools; auto-saves after every stroke
- **Team Docs** — two-pane Markdown knowledge base editor per team
- **Calendar** — month and week views with per-team and per-project event creation
- **File Manager** — per-project, per-team, and personal file/folder browser with upload support
- **Webhooks** — integrations with Discord, GitHub, Gitea, Slack, and custom endpoints
- **Notifications** — inbox with unread indicators, badge count, and mark-as-read
- **API Keys** — personal API keys with granular scopes for programmatic access
- **API Documentation** — built-in interactive API reference page at `/api-docs`
- **RBAC** — hierarchical role flags (Viewer `1`, Editor `2`, Admin `4`, Owner `8`) for fine-grained permission control
- **User Settings** — profile management, avatar upload, password change, and API key management
- **Archived Projects** — projects can be archived; the board becomes read-only (no drag-drop, no card edits, no new cards or columns)
- **Docker Support** — single-command deployment with Docker Compose
## Tech Stack
| Layer | Technology |
|---|---|
| Frontend | SvelteKit + Svelte 5 (Runes), TypeScript |
| Styling | Tailwind CSS v4, JetBrains Mono |
| Icons | @iconify/svelte (Lucide + Simple Icons) |
| Markdown | marked + DOMPurify |
| Backend | Go 1.24 + GoFiber v2 |
| Database | MongoDB 7 |
| Auth | JWT (access 15 min, refresh 7 days) + personal API keys |
| Authorization | RBAC hierarchical role flags |
| Deployment | Docker multi-stage build + Docker Compose |
## Getting Started
### Prerequisites
- [Bun](https://bun.sh) 1.x (or Node.js 22+)
- Go 1.24+
- MongoDB 7+ (local or Atlas)
### Development
Install frontend dependencies and start the dev server:
```bash
bun install
bun run dev
```
In a separate terminal, start the backend:
```bash
cd server
cp example.env .env # edit with your MongoDB URI and secrets
go run ./cmd/api/main.go
```
The frontend dev server runs on `http://localhost:5173` and proxies API requests.
The Go server runs on `http://localhost:8080` and serves both the API and the built frontend in production.
### Production Build (Manual)
```bash
bun run build
cd server && go build -o bin/fpmb ./cmd/api/main.go
./bin/fpmb
```
### Docker (Recommended)
The easiest way to deploy FPMB:
```bash
# Start everything (app + MongoDB)
docker compose up -d
# With custom secrets
JWT_SECRET=my-secret JWT_REFRESH_SECRET=my-refresh-secret docker compose up -d
# Rebuild after code changes
docker compose up -d --build
# View logs
docker compose logs -f app
# Stop
docker compose down
```
This starts:
- **fpmb** — the application on port `8080`
- **fpmb-mongo** — MongoDB 7 on port `27017`
Data is persisted in Docker volumes (`app_data` for uploads, `mongo_data` for the database).
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `PORT` | `8080` | Server listen port |
| `MONGO_URI` | `mongodb://localhost:27017` | MongoDB connection string |
| `MONGO_DB_NAME` | `fpmb` | MongoDB database name |
| `JWT_SECRET` | `changeme-jwt-secret` | Secret for signing access tokens (**change in production**) |
| `JWT_REFRESH_SECRET` | `changeme-refresh-secret` | Secret for signing refresh tokens (**change in production**) |
## API Overview
All routes are under `/api`. Protected endpoints require a `Bearer` token (JWT access token or personal API key).
A full interactive reference is available in-app at `/api-docs`.
### Authentication
| Method | Route | Description |
|---|---|---|
| POST | `/auth/register` | Create a new account |
| POST | `/auth/login` | Login — returns access + refresh tokens |
| POST | `/auth/refresh` | Exchange refresh token for new tokens |
| POST | `/auth/logout` | Logout (requires auth) |
### API Keys
| Method | Route | Description |
|---|---|---|
| GET | `/users/me/api-keys` | List all active API keys |
| POST | `/users/me/api-keys` | Create a new key with scopes |
| DELETE | `/users/me/api-keys/:keyId` | Revoke an API key |
**Available scopes:** `read:projects`, `write:projects`, `read:boards`, `write:boards`, `read:teams`, `write:teams`, `read:files`, `write:files`, `read:notifications`
### Users
| Method | Route | Description |
|---|---|---|
| GET | `/users/me` | Get current user profile |
| PUT | `/users/me` | Update profile (name, email) |
| PUT | `/users/me/password` | Change password |
| POST | `/users/me/avatar` | Upload avatar (multipart) |
| GET | `/users/me/avatar` | Serve avatar image |
| GET | `/users/search?q=` | Search users by name/email |
### Teams
| Method | Route | Description |
|---|---|---|
| GET/POST | `/teams` | List or create teams |
| GET/PUT/DELETE | `/teams/:teamId` | Get, update, or delete a team |
| GET | `/teams/:teamId/members` | List team members |
| POST | `/teams/:teamId/members/invite` | Invite a member |
| PUT/DELETE | `/teams/:teamId/members/:userId` | Update role or remove member |
| GET/POST | `/teams/:teamId/projects` | List or create team projects |
| GET/POST | `/teams/:teamId/events` | List or create team events |
| GET/POST | `/teams/:teamId/docs` | List or create docs |
| GET | `/teams/:teamId/files` | List team files |
| POST | `/teams/:teamId/files/upload` | Upload file (multipart) |
| POST | `/teams/:teamId/avatar` | Upload team avatar |
| POST | `/teams/:teamId/banner` | Upload team banner |
### Projects
| Method | Route | Description |
|---|---|---|
| GET/POST | `/projects` | List all or create personal project |
| GET/PUT/DELETE | `/projects/:projectId` | Get, update, or delete project |
| PUT | `/projects/:projectId/archive` | Toggle archive state |
| GET/POST | `/projects/:projectId/members` | List or add members |
| GET | `/projects/:projectId/board` | Get board (columns + cards) |
| POST | `/projects/:projectId/columns` | Create a column |
| POST | `/projects/:projectId/columns/:columnId/cards` | Create a card |
| GET/POST | `/projects/:projectId/events` | List or create events |
| GET | `/projects/:projectId/files` | List project files |
| POST | `/projects/:projectId/files/upload` | Upload file (multipart) |
| GET/POST | `/projects/:projectId/webhooks` | List or create webhooks |
| GET/PUT | `/projects/:projectId/whiteboard` | Get or save whiteboard |
### Cards, Events, Files, Webhooks, Notifications
| Method | Route | Description |
|---|---|---|
| PUT/DELETE | `/cards/:cardId` | Update or delete a card |
| PUT | `/cards/:cardId/move` | Move card between columns |
| PUT/DELETE | `/events/:eventId` | Update or delete an event |
| GET | `/files/:fileId/download` | Download a file |
| DELETE | `/files/:fileId` | Delete a file |
| PUT/DELETE | `/webhooks/:webhookId` | Update or delete a webhook |
| PUT | `/webhooks/:webhookId/toggle` | Enable/disable a webhook |
| GET | `/notifications` | List notifications |
| PUT | `/notifications/read-all` | Mark all as read |
| PUT | `/notifications/:notifId/read` | Mark one as read |
| DELETE | `/notifications/:notifId` | Delete a notification |
## License
MIT

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
services:
app:
build: .
container_name: fpmb
restart: unless-stopped
ports:
- "8080:8080"
environment:
- PORT=8080
- MONGO_URI=mongodb://mongo:27017
- MONGO_DB_NAME=fpmb
- JWT_SECRET=${JWT_SECRET:-changeme-jwt-secret}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-changeme-refresh-secret}
volumes:
- app_data:/app/data
depends_on:
mongo:
condition: service_healthy
mongo:
image: mongo:7
container_name: fpmb-mongo
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
app_data:
mongo_data:

5
example.env Normal file
View File

@@ -0,0 +1,5 @@
PORT=8080
MONGO_URI=mongodb://localhost:27017
MONGO_DB_NAME=fpmb
JWT_SECRET=changeme-jwt-secret
JWT_REFRESH_SECRET=changeme-refresh-secret

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "fpmb",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@iconify/svelte": "^5.2.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@types/dompurify": "^3.2.0",
"dompurify": "^3.3.1",
"marked": "^17.0.3",
"svelte": "^5.53.6",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

BIN
server/bin/openboard Executable file

Binary file not shown.

246
server/cmd/api/main.go Normal file
View File

@@ -0,0 +1,246 @@
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/handlers"
"github.com/fpmb/server/internal/middleware"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/websocket/v2"
"github.com/joho/godotenv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func startDueDateReminder() {
ticker := time.NewTicker(1 * time.Hour)
go func() {
runDueDateReminder()
for range ticker.C {
runDueDateReminder()
}
}()
}
func runDueDateReminder() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
now := time.Now()
thresholds := []int{1, 3}
for _, days := range thresholds {
windowStart := now.Add(time.Duration(days)*24*time.Hour - 30*time.Minute)
windowEnd := now.Add(time.Duration(days)*24*time.Hour + 30*time.Minute)
cursor, err := database.GetCollection("cards").Find(ctx, bson.M{
"due_date": bson.M{"$gte": windowStart, "$lte": windowEnd},
})
if err != nil {
continue
}
var cards []models.Card
cursor.All(ctx, &cards)
cursor.Close(ctx)
for _, card := range cards {
for _, email := range card.Assignees {
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&user); err != nil {
continue
}
cutoff := now.Add(-24 * time.Hour)
count, _ := database.GetCollection("notifications").CountDocuments(ctx, bson.M{
"user_id": user.ID,
"type": "due_soon",
"card_id": card.ID,
"created_at": bson.M{"$gte": cutoff},
})
if count > 0 {
continue
}
msg := fmt.Sprintf("Task \"%s\" is due in %d day(s)", card.Title, days)
n := &models.Notification{
ID: primitive.NewObjectID(),
UserID: user.ID,
Type: "due_soon",
Message: msg,
ProjectID: card.ProjectID,
CardID: card.ID,
Read: false,
CreatedAt: now,
}
database.GetCollection("notifications").InsertOne(ctx, n)
}
}
}
}
func main() {
err := godotenv.Load()
if err != nil {
log.Println("No .env file found, using system environment variables")
}
database.Connect()
startDueDateReminder()
app := fiber.New(fiber.Config{
AppName: "FPMB API",
})
app.Use(logger.New(logger.Config{
Next: func(c *fiber.Ctx) bool {
return len(c.Path()) >= 5 && c.Path()[:5] == "/_app" ||
c.Path() == "/favicon.ico" ||
len(c.Path()) >= 7 && c.Path()[:7] == "/fonts/"
},
}))
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
}))
api := app.Group("/api")
api.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "message": "FPMB API is running"})
})
auth := api.Group("/auth")
auth.Post("/register", handlers.Register)
auth.Post("/login", handlers.Login)
auth.Post("/refresh", handlers.RefreshToken)
auth.Post("/logout", middleware.Protected(), handlers.Logout)
users := api.Group("/users", middleware.Protected())
users.Get("/me", handlers.GetMe)
users.Put("/me", handlers.UpdateMe)
users.Put("/me/password", handlers.ChangePassword)
users.Get("/search", handlers.SearchUsers)
users.Post("/me/avatar", handlers.UploadUserAvatar)
users.Get("/me/avatar", handlers.ServeUserAvatar)
users.Get("/me/files", handlers.ListUserFiles)
users.Post("/me/files/folder", handlers.CreateUserFolder)
users.Post("/me/files/upload", handlers.UploadUserFile)
users.Get("/me/api-keys", handlers.ListAPIKeys)
users.Post("/me/api-keys", handlers.CreateAPIKey)
users.Delete("/me/api-keys/:keyId", handlers.RevokeAPIKey)
teams := api.Group("/teams", middleware.Protected())
teams.Get("/", handlers.ListTeams)
teams.Post("/", handlers.CreateTeam)
teams.Get("/:teamId", handlers.GetTeam)
teams.Put("/:teamId", handlers.UpdateTeam)
teams.Delete("/:teamId", handlers.DeleteTeam)
teams.Get("/:teamId/members", handlers.ListTeamMembers)
teams.Post("/:teamId/members/invite", handlers.InviteTeamMember)
teams.Put("/:teamId/members/:userId", handlers.UpdateTeamMemberRole)
teams.Delete("/:teamId/members/:userId", handlers.RemoveTeamMember)
teams.Get("/:teamId/projects", handlers.ListTeamProjects)
teams.Post("/:teamId/projects", handlers.CreateProject)
teams.Get("/:teamId/events", handlers.ListTeamEvents)
teams.Post("/:teamId/events", handlers.CreateTeamEvent)
teams.Get("/:teamId/docs", handlers.ListDocs)
teams.Post("/:teamId/docs", handlers.CreateDoc)
teams.Get("/:teamId/files", handlers.ListTeamFiles)
teams.Post("/:teamId/files/folder", handlers.CreateTeamFolder)
teams.Post("/:teamId/files/upload", handlers.UploadTeamFile)
teams.Post("/:teamId/avatar", handlers.UploadTeamAvatar)
teams.Get("/:teamId/avatar", handlers.ServeTeamAvatar)
teams.Post("/:teamId/banner", handlers.UploadTeamBanner)
teams.Get("/:teamId/banner", handlers.ServeTeamBanner)
teams.Get("/:teamId/chat", handlers.ListChatMessages)
projects := api.Group("/projects", middleware.Protected())
projects.Get("/", handlers.ListProjects)
projects.Post("/", handlers.CreatePersonalProject)
projects.Get("/:projectId", handlers.GetProject)
projects.Put("/:projectId", handlers.UpdateProject)
projects.Put("/:projectId/archive", handlers.ArchiveProject)
projects.Delete("/:projectId", handlers.DeleteProject)
projects.Get("/:projectId/members", handlers.ListProjectMembers)
projects.Post("/:projectId/members", handlers.AddProjectMember)
projects.Put("/:projectId/members/:userId", handlers.UpdateProjectMemberRole)
projects.Delete("/:projectId/members/:userId", handlers.RemoveProjectMember)
projects.Get("/:projectId/board", handlers.GetBoard)
projects.Post("/:projectId/columns", handlers.CreateColumn)
projects.Put("/:projectId/columns/:columnId", handlers.UpdateColumn)
projects.Put("/:projectId/columns/:columnId/position", handlers.ReorderColumn)
projects.Delete("/:projectId/columns/:columnId", handlers.DeleteColumn)
projects.Post("/:projectId/columns/:columnId/cards", handlers.CreateCard)
projects.Get("/:projectId/events", handlers.ListProjectEvents)
projects.Post("/:projectId/events", handlers.CreateProjectEvent)
projects.Get("/:projectId/files", handlers.ListFiles)
projects.Post("/:projectId/files/folder", handlers.CreateFolder)
projects.Post("/:projectId/files/upload", handlers.UploadFile)
projects.Get("/:projectId/webhooks", handlers.ListWebhooks)
projects.Post("/:projectId/webhooks", handlers.CreateWebhook)
projects.Get("/:projectId/whiteboard", handlers.GetWhiteboard)
projects.Put("/:projectId/whiteboard", handlers.SaveWhiteboard)
cards := api.Group("/cards", middleware.Protected())
cards.Put("/:cardId", handlers.UpdateCard)
cards.Put("/:cardId/move", handlers.MoveCard)
cards.Delete("/:cardId", handlers.DeleteCard)
events := api.Group("/events", middleware.Protected())
events.Put("/:eventId", handlers.UpdateEvent)
events.Delete("/:eventId", handlers.DeleteEvent)
notifications := api.Group("/notifications", middleware.Protected())
notifications.Get("/", handlers.ListNotifications)
notifications.Put("/read-all", handlers.MarkAllNotificationsRead)
notifications.Put("/:notifId/read", handlers.MarkNotificationRead)
notifications.Delete("/:notifId", handlers.DeleteNotification)
docs := api.Group("/docs", middleware.Protected())
docs.Get("/:docId", handlers.GetDoc)
docs.Put("/:docId", handlers.UpdateDoc)
docs.Delete("/:docId", handlers.DeleteDoc)
files := api.Group("/files", middleware.Protected())
files.Get("/:fileId/download", handlers.DownloadFile)
files.Delete("/:fileId", handlers.DeleteFile)
webhooks := api.Group("/webhooks", middleware.Protected())
webhooks.Put("/:webhookId", handlers.UpdateWebhook)
webhooks.Put("/:webhookId/toggle", handlers.ToggleWebhook)
webhooks.Delete("/:webhookId", handlers.DeleteWebhook)
app.Use("/ws", func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
return c.Next()
}
return fiber.ErrUpgradeRequired
})
app.Get("/ws/whiteboard/:id", websocket.New(handlers.WhiteboardWS))
app.Get("/ws/team/:id/chat", websocket.New(handlers.TeamChatWS))
app.Static("/", "../build")
app.Get("/*", func(c *fiber.Ctx) error {
if len(c.Path()) > 4 && c.Path()[:4] == "/api" {
return c.Status(404).JSON(fiber.Map{"error": "Not Found"})
}
return c.SendFile("../build/index.html")
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on port %s", port)
log.Fatal(app.Listen(":" + port))
}

36
server/go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/fpmb/server
go 1.24.0
require (
github.com/gofiber/fiber/v2 v2.52.12
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/crypto v0.48.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/fasthttp/websocket v1.5.3 // indirect
github.com/gofiber/websocket/v2 v2.2.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

85
server/go.sum Normal file
View File

@@ -0,0 +1,85 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -0,0 +1,50 @@
package database
import (
"context"
"fmt"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var DB *mongo.Database
var Client *mongo.Client
func Connect() {
uri := os.Getenv("MONGO_URI")
if uri == "" {
uri = "mongodb://localhost:27017"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clientOptions := options.Client().ApplyURI(uri)
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
log.Fatal("Failed to connect to MongoDB: ", err)
}
err = client.Ping(ctx, nil)
if err != nil {
log.Fatal("Failed to ping MongoDB: ", err)
}
fmt.Println("Successfully connected to MongoDB!")
Client = client
dbName := os.Getenv("MONGO_DB_NAME")
if dbName == "" {
dbName = "fpmb"
}
DB = client.Database(dbName)
}
func GetCollection(collectionName string) *mongo.Collection {
return DB.Collection(collectionName)
}

View File

@@ -0,0 +1,165 @@
package handlers
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// generateAPIKey returns a 32-byte random hex token (64 chars) prefixed with "fpmb_".
func generateAPIKey() (raw string, hashed string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
raw = "fpmb_" + hex.EncodeToString(b)
sum := sha256.Sum256([]byte(raw))
hashed = hex.EncodeToString(sum[:])
return
}
// ListAPIKeys returns all non-revoked API keys for the current user (without exposing hashes).
func ListAPIKeys(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cursor, err := database.GetCollection("api_keys").Find(ctx, bson.M{
"user_id": userID,
"revoked_at": bson.M{"$exists": false},
})
if err != nil {
return c.JSON([]fiber.Map{})
}
defer cursor.Close(ctx)
var keys []models.APIKey
cursor.All(ctx, &keys)
// Strip the hash before returning.
type SafeKey struct {
ID string `json:"id"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
Prefix string `json:"prefix"`
LastUsed *time.Time `json:"last_used,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
result := []SafeKey{}
for _, k := range keys {
result = append(result, SafeKey{
ID: k.ID.Hex(),
Name: k.Name,
Scopes: k.Scopes,
Prefix: k.Prefix,
LastUsed: k.LastUsed,
CreatedAt: k.CreatedAt,
})
}
return c.JSON(result)
}
// CreateAPIKey generates a new API key and stores its hash.
func CreateAPIKey(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
}
if len(body.Scopes) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "at least one scope is required"})
}
// Validate scopes.
valid := map[string]bool{
"read:projects": true, "write:projects": true,
"read:boards": true, "write:boards": true,
"read:teams": true, "write:teams": true,
"read:files": true, "write:files": true,
"read:notifications": true,
}
for _, s := range body.Scopes {
if !valid[s] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "unknown scope: " + s})
}
}
raw, hashed, err := generateAPIKey()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate key"})
}
now := time.Now()
key := models.APIKey{
ID: primitive.NewObjectID(),
UserID: userID,
Name: body.Name,
Scopes: body.Scopes,
KeyHash: hashed,
Prefix: raw[:10], // "fpmb_" + first 5 chars of random
CreatedAt: now,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := database.GetCollection("api_keys").InsertOne(ctx, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store key"})
}
// Return the raw key only once.
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"id": key.ID.Hex(),
"name": key.Name,
"scopes": key.Scopes,
"prefix": key.Prefix,
"key": raw,
"created_at": key.CreatedAt,
})
}
// RevokeAPIKey soft-deletes an API key belonging to the current user.
func RevokeAPIKey(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
keyID, err := primitive.ObjectIDFromHex(c.Params("keyId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid key ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
now := time.Now()
res, err := database.GetCollection("api_keys").UpdateOne(ctx,
bson.M{"_id": keyID, "user_id": userID},
bson.M{"$set": bson.M{"revoked_at": now}},
)
if err != nil || res.MatchedCount == 0 {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Key not found"})
}
return c.JSON(fiber.Map{"message": "Key revoked"})
}

View File

@@ -0,0 +1,199 @@
package handlers
import (
"context"
"os"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/middleware"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"golang.org/x/crypto/bcrypt"
)
func jwtSecret() []byte {
s := os.Getenv("JWT_SECRET")
if s == "" {
s = "changeme-jwt-secret"
}
return []byte(s)
}
func jwtRefreshSecret() []byte {
s := os.Getenv("JWT_REFRESH_SECRET")
if s == "" {
s = "changeme-refresh-secret"
}
return []byte(s)
}
func generateTokens(user *models.User) (string, string, error) {
accessClaims := &middleware.JWTClaims{
UserID: user.ID.Hex(),
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(jwtSecret())
if err != nil {
return "", "", err
}
refreshClaims := &middleware.JWTClaims{
UserID: user.ID.Hex(),
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtRefreshSecret())
if err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
func Register(c *fiber.Ctx) error {
var body struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" || body.Email == "" || body.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name, email and password are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
col := database.GetCollection("users")
existing := col.FindOne(ctx, bson.M{"email": body.Email})
if existing.Err() == nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Email already in use"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"})
}
now := time.Now()
user := &models.User{
ID: primitive.NewObjectID(),
Name: body.Name,
Email: body.Email,
PasswordHash: string(hash),
CreatedAt: now,
UpdatedAt: now,
}
if _, err := col.InsertOne(ctx, user); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"})
}
access, refresh, err := generateTokens(user)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
"user": fiber.Map{"id": user.ID, "name": user.Name, "email": user.Email},
})
}
func Login(c *fiber.Ctx) error {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Email == "" || body.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email and password are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var user models.User
err := database.GetCollection("users").FindOne(ctx, bson.M{"email": body.Email}).Decode(&user)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
access, refresh, err := generateTokens(&user)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"})
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
"user": fiber.Map{"id": user.ID, "name": user.Name, "email": user.Email},
})
}
func RefreshToken(c *fiber.Ctx) error {
var body struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.BodyParser(&body); err != nil || body.RefreshToken == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refresh_token is required"})
}
claims := &middleware.JWTClaims{}
token, err := jwt.ParseWithClaims(body.RefreshToken, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fiber.ErrUnauthorized
}
return jwtRefreshSecret(), nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired refresh token"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
userID, err := primitive.ObjectIDFromHex(claims.UserID)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid token claims"})
}
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"})
}
access, newRefresh, err := generateTokens(&user)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"})
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": newRefresh,
})
}
func Logout(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "Logged out successfully"})
}

View File

@@ -0,0 +1,484 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetBoard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
colCursor, err := database.GetCollection("board_columns").Find(ctx,
bson.M{"project_id": projectID},
options.Find().SetSort(bson.M{"position": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch columns"})
}
defer colCursor.Close(ctx)
var columns []models.BoardColumn
colCursor.All(ctx, &columns)
type ColumnWithCards struct {
models.BoardColumn
Cards []models.Card `json:"cards"`
}
result := []ColumnWithCards{}
for _, col := range columns {
cardCursor, err := database.GetCollection("cards").Find(ctx,
bson.M{"column_id": col.ID},
options.Find().SetSort(bson.M{"position": 1}))
if err != nil {
result = append(result, ColumnWithCards{BoardColumn: col, Cards: []models.Card{}})
continue
}
var cards []models.Card
cardCursor.All(ctx, &cards)
cardCursor.Close(ctx)
if cards == nil {
cards = []models.Card{}
}
result = append(result, ColumnWithCards{BoardColumn: col, Cards: cards})
}
return c.JSON(fiber.Map{"project_id": projectID, "columns": result})
}
func CreateColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Title string `json:"title"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
count, _ := database.GetCollection("board_columns").CountDocuments(ctx, bson.M{"project_id": projectID})
now := time.Now()
col := &models.BoardColumn{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Title: body.Title,
Position: int(count),
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("board_columns").InsertOne(ctx, col)
return c.Status(fiber.StatusCreated).JSON(col)
}
func UpdateColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
var body struct {
Title string `json:"title"`
}
c.BodyParser(&body)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
update := bson.M{"updated_at": time.Now()}
if body.Title != "" {
update["title"] = body.Title
}
col := database.GetCollection("board_columns")
col.UpdateOne(ctx, bson.M{"_id": columnID, "project_id": projectID}, bson.M{"$set": update})
var column models.BoardColumn
col.FindOne(ctx, bson.M{"_id": columnID}).Decode(&column)
return c.JSON(column)
}
func ReorderColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
var body struct {
Position int `json:"position"`
}
c.BodyParser(&body)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("board_columns").UpdateOne(ctx,
bson.M{"_id": columnID, "project_id": projectID},
bson.M{"$set": bson.M{"position": body.Position, "updated_at": time.Now()}},
)
return c.JSON(fiber.Map{"id": columnID, "position": body.Position})
}
func DeleteColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("board_columns").DeleteOne(ctx, bson.M{"_id": columnID, "project_id": projectID})
database.GetCollection("cards").DeleteMany(ctx, bson.M{"column_id": columnID})
return c.JSON(fiber.Map{"message": "Column deleted"})
}
func CreateCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
var body struct {
Title string `json:"title"`
Description string `json:"description"`
Priority string `json:"priority"`
Color string `json:"color"`
DueDate string `json:"due_date"`
Assignees []string `json:"assignees"`
Subtasks []models.Subtask `json:"subtasks"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Title == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
count, _ := database.GetCollection("cards").CountDocuments(ctx, bson.M{"column_id": columnID})
now := time.Now()
if body.Assignees == nil {
body.Assignees = []string{}
}
if body.Subtasks == nil {
body.Subtasks = []models.Subtask{}
}
if body.Priority == "" {
body.Priority = "Medium"
}
if body.Color == "" {
body.Color = "neutral"
}
var dueDate *time.Time
if body.DueDate != "" {
if parsed, parseErr := time.Parse("2006-01-02", body.DueDate); parseErr == nil {
dueDate = &parsed
}
}
card := &models.Card{
ID: primitive.NewObjectID(),
ColumnID: columnID,
ProjectID: projectID,
Title: body.Title,
Description: body.Description,
Priority: body.Priority,
Color: body.Color,
DueDate: dueDate,
Assignees: body.Assignees,
Subtasks: body.Subtasks,
Position: int(count),
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("cards").InsertOne(ctx, card)
for _, email := range card.Assignees {
var assignee models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&assignee); err != nil {
continue
}
if assignee.ID == userID {
continue
}
createNotification(ctx, assignee.ID, "assign",
"You have been assigned to the task \""+card.Title+"\"",
card.ProjectID, card.ID)
}
return c.Status(fiber.StatusCreated).JSON(card)
}
func UpdateCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
cardID, err := primitive.ObjectIDFromHex(c.Params("cardId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var existing models.Card
if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&existing); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"})
}
roleFlags, err := getProjectRole(ctx, existing.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Title *string `json:"title"`
Description *string `json:"description"`
Priority *string `json:"priority"`
Color *string `json:"color"`
DueDate *string `json:"due_date"`
Assignees []string `json:"assignees"`
Subtasks []models.Subtask `json:"subtasks"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Title != nil {
update["title"] = *body.Title
}
if body.Description != nil {
update["description"] = *body.Description
}
if body.Priority != nil {
update["priority"] = *body.Priority
}
if body.Color != nil {
update["color"] = *body.Color
}
if body.DueDate != nil {
if *body.DueDate == "" {
update["due_date"] = nil
} else if parsed, parseErr := time.Parse("2006-01-02", *body.DueDate); parseErr == nil {
update["due_date"] = parsed
}
}
if body.Assignees != nil {
update["assignees"] = body.Assignees
}
if body.Subtasks != nil {
update["subtasks"] = body.Subtasks
}
col := database.GetCollection("cards")
col.UpdateOne(ctx, bson.M{"_id": cardID}, bson.M{"$set": update})
var card models.Card
col.FindOne(ctx, bson.M{"_id": cardID}).Decode(&card)
if body.Assignees != nil {
existingSet := make(map[string]bool)
for _, e := range existing.Assignees {
existingSet[e] = true
}
for _, email := range body.Assignees {
if existingSet[email] {
continue
}
var assignee models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&assignee); err != nil {
continue
}
if assignee.ID == userID {
continue
}
createNotification(ctx, assignee.ID, "assign",
"You have been assigned to the task \""+card.Title+"\"",
card.ProjectID, card.ID)
}
}
return c.JSON(card)
}
func MoveCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
cardID, err := primitive.ObjectIDFromHex(c.Params("cardId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
var body struct {
ColumnID string `json:"column_id"`
Position int `json:"position"`
}
if err := c.BodyParser(&body); err != nil || body.ColumnID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "column_id is required"})
}
newColumnID, err := primitive.ObjectIDFromHex(body.ColumnID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column_id"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var card models.Card
if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&card); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"})
}
roleFlags, err := getProjectRole(ctx, card.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
col := database.GetCollection("cards")
col.UpdateOne(ctx, bson.M{"_id": cardID}, bson.M{"$set": bson.M{
"column_id": newColumnID,
"position": body.Position,
"updated_at": time.Now(),
}})
var updated models.Card
col.FindOne(ctx, bson.M{"_id": cardID}).Decode(&updated)
return c.JSON(updated)
}
func DeleteCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
cardID, err := primitive.ObjectIDFromHex(c.Params("cardId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var card models.Card
if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&card); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"})
}
roleFlags, err := getProjectRole(ctx, card.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("cards").DeleteOne(ctx, bson.M{"_id": cardID})
return c.JSON(fiber.Map{"message": "Card deleted"})
}

View File

@@ -0,0 +1,238 @@
package handlers
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
type chatRoom struct {
clients map[*websocket.Conn]*wsClient
mu sync.RWMutex
}
var chatRooms = struct {
m map[string]*chatRoom
mu sync.RWMutex
}{m: make(map[string]*chatRoom)}
func getChatRoom(teamID string) *chatRoom {
chatRooms.mu.Lock()
defer chatRooms.mu.Unlock()
if room, ok := chatRooms.m[teamID]; ok {
return room
}
room := &chatRoom{clients: make(map[*websocket.Conn]*wsClient)}
chatRooms.m[teamID] = room
return room
}
func (r *chatRoom) broadcast(sender *websocket.Conn, msg []byte) {
r.mu.RLock()
defer r.mu.RUnlock()
for conn := range r.clients {
if conn != sender {
_ = conn.WriteMessage(websocket.TextMessage, msg)
}
}
}
func (r *chatRoom) broadcastAll(msg []byte) {
r.mu.RLock()
defer r.mu.RUnlock()
for conn := range r.clients {
_ = conn.WriteMessage(websocket.TextMessage, msg)
}
}
func (r *chatRoom) onlineUsers() []map[string]string {
r.mu.RLock()
defer r.mu.RUnlock()
seen := map[string]bool{}
list := make([]map[string]string, 0)
for _, c := range r.clients {
if !seen[c.userID] {
seen[c.userID] = true
list = append(list, map[string]string{"user_id": c.userID, "name": c.name})
}
}
return list
}
func ListChatMessages(c *fiber.Ctx) error {
teamID := c.Params("teamId")
teamOID, err := primitive.ObjectIDFromHex(teamID)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid team ID"})
}
limitStr := c.Query("limit", "50")
limit := int64(50)
if l, err := primitive.ParseDecimal128(limitStr); err == nil {
if s := l.String(); s != "" {
if n, err := parseIntFromString(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
}
beforeStr := c.Query("before", "")
filter := bson.M{"team_id": teamOID}
if beforeStr != "" {
if beforeID, err := primitive.ObjectIDFromHex(beforeStr); err == nil {
filter["_id"] = bson.M{"$lt": beforeID}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opts := options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(limit)
cursor, err := database.GetCollection("chat_messages").Find(ctx, filter, opts)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to fetch messages"})
}
defer cursor.Close(ctx)
var messages []models.ChatMessage
if err := cursor.All(ctx, &messages); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to decode messages"})
}
if messages == nil {
messages = []models.ChatMessage{}
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
return c.JSON(messages)
}
func parseIntFromString(s string) (int64, error) {
var n int64
for _, ch := range s {
if ch < '0' || ch > '9' {
break
}
n = n*10 + int64(ch-'0')
}
return n, nil
}
func TeamChatWS(c *websocket.Conn) {
teamID := c.Params("id")
tokenStr := c.Query("token", "")
userName := c.Query("name", "Anonymous")
userID, _, ok := parseWSToken(tokenStr)
if !ok {
_ = c.WriteJSON(map[string]string{"type": "error", "message": "unauthorized"})
_ = c.Close()
return
}
room := getChatRoom(teamID)
client := &wsClient{conn: c, userID: userID, name: userName}
room.mu.Lock()
room.clients[c] = client
room.mu.Unlock()
presenceMsg, _ := json.Marshal(map[string]interface{}{
"type": "presence",
"users": room.onlineUsers(),
})
room.broadcastAll(presenceMsg)
defer func() {
room.mu.Lock()
delete(room.clients, c)
empty := len(room.clients) == 0
room.mu.Unlock()
leaveMsg, _ := json.Marshal(map[string]interface{}{
"type": "presence",
"users": room.onlineUsers(),
})
room.broadcast(nil, leaveMsg)
if empty {
chatRooms.mu.Lock()
delete(chatRooms.m, teamID)
chatRooms.mu.Unlock()
}
}()
for {
_, msg, err := c.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
// unexpected error
}
break
}
var incoming struct {
Type string `json:"type"`
Content string `json:"content"`
}
if json.Unmarshal(msg, &incoming) != nil {
continue
}
if incoming.Type == "message" {
content := strings.TrimSpace(incoming.Content)
if content == "" || len(content) > 5000 {
continue
}
teamOID, err := primitive.ObjectIDFromHex(teamID)
if err != nil {
continue
}
userOID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
continue
}
chatMsg := models.ChatMessage{
ID: primitive.NewObjectID(),
TeamID: teamOID,
UserID: userOID,
UserName: userName,
Content: content,
CreatedAt: time.Now(),
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = database.GetCollection("chat_messages").InsertOne(ctx, chatMsg)
cancel()
outMsg, _ := json.Marshal(map[string]interface{}{
"type": "message",
"message": chatMsg,
})
room.broadcastAll(outMsg)
}
if incoming.Type == "typing" {
typingMsg, _ := json.Marshal(map[string]interface{}{
"type": "typing",
"user_id": userID,
"name": userName,
})
room.broadcast(c, typingMsg)
}
}
}

View File

@@ -0,0 +1,221 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ListDocs(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("docs").Find(ctx,
bson.M{"team_id": teamID},
options.Find().SetSort(bson.M{"updated_at": -1}).SetProjection(bson.M{"content": 0}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch docs"})
}
defer cursor.Close(ctx)
var docs []models.Doc
cursor.All(ctx, &docs)
if docs == nil {
docs = []models.Doc{}
}
return c.JSON(docs)
}
func CreateDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
doc := &models.Doc{
ID: primitive.NewObjectID(),
TeamID: teamID,
Title: body.Title,
Content: body.Content,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("docs").InsertOne(ctx, doc)
docDir := filepath.Join("../data/teams", teamID.Hex(), "docs")
if err := os.MkdirAll(docDir, 0755); err != nil {
log.Printf("CreateDoc: mkdir %s: %v", docDir, err)
} else {
content := fmt.Sprintf("# %s\n\n%s", doc.Title, doc.Content)
if err := os.WriteFile(filepath.Join(docDir, doc.ID.Hex()+".md"), []byte(content), 0644); err != nil {
log.Printf("CreateDoc: write file: %v", err)
}
}
return c.Status(fiber.StatusCreated).JSON(doc)
}
func GetDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
docID, err := primitive.ObjectIDFromHex(c.Params("docId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var doc models.Doc
if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&doc); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"})
}
if _, err := getTeamRole(ctx, doc.TeamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
return c.JSON(doc)
}
func UpdateDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
docID, err := primitive.ObjectIDFromHex(c.Params("docId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var existing models.Doc
if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&existing); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"})
}
roleFlags, err := getTeamRole(ctx, existing.TeamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Title != "" {
update["title"] = body.Title
}
if body.Content != "" {
update["content"] = body.Content
}
col := database.GetCollection("docs")
col.UpdateOne(ctx, bson.M{"_id": docID}, bson.M{"$set": update})
var doc models.Doc
col.FindOne(ctx, bson.M{"_id": docID}).Decode(&doc)
docDir := filepath.Join("../data/teams", existing.TeamID.Hex(), "docs")
if err := os.MkdirAll(docDir, 0755); err != nil {
log.Printf("UpdateDoc: mkdir %s: %v", docDir, err)
} else {
content := fmt.Sprintf("# %s\n\n%s", doc.Title, doc.Content)
if err := os.WriteFile(filepath.Join(docDir, doc.ID.Hex()+".md"), []byte(content), 0644); err != nil {
log.Printf("UpdateDoc: write file: %v", err)
}
}
return c.JSON(doc)
}
func DeleteDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
docID, err := primitive.ObjectIDFromHex(c.Params("docId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var doc models.Doc
if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&doc); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"})
}
roleFlags, err := getTeamRole(ctx, doc.TeamID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("docs").DeleteOne(ctx, bson.M{"_id": docID})
mdPath := filepath.Join("../data/teams", doc.TeamID.Hex(), "docs", docID.Hex()+".md")
if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) {
log.Printf("DeleteDoc: remove file %s: %v", mdPath, err)
}
return c.JSON(fiber.Map{"message": "Doc deleted"})
}

View File

@@ -0,0 +1,285 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ListTeamEvents(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"scope_id": teamID, "scope": "org"}
if month := c.Query("month"); month != "" {
filter["date"] = bson.M{"$regex": "^" + month}
}
cursor, err := database.GetCollection("events").Find(ctx, filter,
options.Find().SetSort(bson.M{"date": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch events"})
}
defer cursor.Close(ctx)
var events []models.Event
cursor.All(ctx, &events)
if events == nil {
events = []models.Event{}
}
return c.JSON(events)
}
func CreateTeamEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Title string `json:"title"`
Date string `json:"date"`
Time string `json:"time"`
Color string `json:"color"`
Description string `json:"description"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Date == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "title and date are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
event := &models.Event{
ID: primitive.NewObjectID(),
Title: body.Title,
Date: body.Date,
Time: body.Time,
Color: body.Color,
Description: body.Description,
Scope: "org",
ScopeID: teamID,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("events").InsertOne(ctx, event)
return c.Status(fiber.StatusCreated).JSON(event)
}
func ListProjectEvents(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"scope_id": projectID, "scope": "project"}
if month := c.Query("month"); month != "" {
filter["date"] = bson.M{"$regex": "^" + month}
}
cursor, err := database.GetCollection("events").Find(ctx, filter,
options.Find().SetSort(bson.M{"date": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch events"})
}
defer cursor.Close(ctx)
var events []models.Event
cursor.All(ctx, &events)
if events == nil {
events = []models.Event{}
}
return c.JSON(events)
}
func CreateProjectEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Title string `json:"title"`
Date string `json:"date"`
Time string `json:"time"`
Color string `json:"color"`
Description string `json:"description"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Date == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "title and date are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
event := &models.Event{
ID: primitive.NewObjectID(),
Title: body.Title,
Date: body.Date,
Time: body.Time,
Color: body.Color,
Description: body.Description,
Scope: "project",
ScopeID: projectID,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("events").InsertOne(ctx, event)
return c.Status(fiber.StatusCreated).JSON(event)
}
func UpdateEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
eventID, err := primitive.ObjectIDFromHex(c.Params("eventId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid event ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var event models.Event
if err := database.GetCollection("events").FindOne(ctx, bson.M{"_id": eventID}).Decode(&event); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Event not found"})
}
var roleFlags int
var roleErr error
if event.Scope == "org" {
roleFlags, roleErr = getTeamRole(ctx, event.ScopeID, userID)
} else {
roleFlags, roleErr = getProjectRole(ctx, event.ScopeID, userID)
}
if roleErr != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Title string `json:"title"`
Date string `json:"date"`
Time string `json:"time"`
Color string `json:"color"`
Description string `json:"description"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Title != "" {
update["title"] = body.Title
}
if body.Date != "" {
update["date"] = body.Date
}
if body.Time != "" {
update["time"] = body.Time
}
if body.Color != "" {
update["color"] = body.Color
}
if body.Description != "" {
update["description"] = body.Description
}
col := database.GetCollection("events")
col.UpdateOne(ctx, bson.M{"_id": eventID}, bson.M{"$set": update})
var updated models.Event
col.FindOne(ctx, bson.M{"_id": eventID}).Decode(&updated)
return c.JSON(updated)
}
func DeleteEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
eventID, err := primitive.ObjectIDFromHex(c.Params("eventId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid event ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var event models.Event
if err := database.GetCollection("events").FindOne(ctx, bson.M{"_id": eventID}).Decode(&event); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Event not found"})
}
var roleFlags int
var roleErr error
if event.Scope == "org" {
roleFlags, roleErr = getTeamRole(ctx, event.ScopeID, userID)
} else {
roleFlags, roleErr = getProjectRole(ctx, event.ScopeID, userID)
}
if roleErr != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("events").DeleteOne(ctx, bson.M{"_id": eventID})
return c.JSON(fiber.Map{"message": "Event deleted"})
}

View File

@@ -0,0 +1,590 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func storageBase(ctx context.Context, projectID primitive.ObjectID) (string, error) {
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return "", err
}
if project.TeamID == primitive.NilObjectID {
return filepath.Join("../data/users", project.CreatedBy.Hex()), nil
}
return filepath.Join("../data/teams", project.TeamID.Hex()), nil
}
func ListFiles(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
log.Printf("ListFiles getProjectRole error: %v (projectID=%s userID=%s)", err, projectID.Hex(), userID.Hex())
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"project_id": projectID}
if parentID := c.Query("parent_id"); parentID != "" {
oid, err := primitive.ObjectIDFromHex(parentID)
if err == nil {
filter["parent_id"] = oid
}
} else {
filter["parent_id"] = bson.M{"$exists": false}
}
cursor, err := database.GetCollection("files").Find(ctx, filter,
options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}}))
if err != nil {
log.Printf("ListFiles Find error: %v (filter=%v)", err, filter)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"})
}
defer cursor.Close(ctx)
var files []models.File
cursor.All(ctx, &files)
if files == nil {
files = []models.File{}
}
return c.JSON(files)
}
func CreateFolder(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Name: body.Name,
Type: "folder",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.ParentID != "" {
if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func UploadFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
base, err := storageBase(ctx, projectID)
if err != nil {
log.Printf("UploadFile storageBase error: %v (projectID=%s)", err, projectID.Hex())
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve storage path"})
}
if err := os.MkdirAll(base, 0755); err != nil {
log.Printf("UploadFile MkdirAll error: %v (base=%s)", err, base)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
filename := fh.Filename
ext := filepath.Ext(filename)
stem := filename[:len(filename)-len(ext)]
destPath := filepath.Join(base, filename)
for n := 2; ; n++ {
if _, statErr := os.Stat(destPath); statErr != nil {
break
}
filename = fmt.Sprintf("%s (%d)%s", stem, n, ext)
destPath = filepath.Join(base, filename)
}
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadFile SaveFile error: %v (destPath=%s)", err, destPath)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"})
}
relPath := destPath[len("../data/"):]
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Name: fh.Filename,
Type: "file",
SizeBytes: fh.Size,
StorageURL: relPath,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
parentID := c.FormValue("parent_id")
if parentID != "" {
if oid, err := primitive.ObjectIDFromHex(parentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func ListTeamFiles(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"team_id": teamID}
if parentID := c.Query("parent_id"); parentID != "" {
oid, err := primitive.ObjectIDFromHex(parentID)
if err == nil {
filter["parent_id"] = oid
}
} else {
filter["parent_id"] = bson.M{"$exists": false}
}
cursor, err := database.GetCollection("files").Find(ctx, filter,
options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"})
}
defer cursor.Close(ctx)
var files []models.File
cursor.All(ctx, &files)
if files == nil {
files = []models.File{}
}
return c.JSON(files)
}
func CreateTeamFolder(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
TeamID: teamID,
Name: body.Name,
Type: "folder",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.ParentID != "" {
if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func UploadTeamFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
base := filepath.Join("../data/teams", teamID.Hex(), "files")
if err := os.MkdirAll(base, 0755); err != nil {
log.Printf("UploadTeamFile MkdirAll error: %v (base=%s)", err, base)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
filename := fh.Filename
ext := filepath.Ext(filename)
stem := filename[:len(filename)-len(ext)]
destPath := filepath.Join(base, filename)
for n := 2; ; n++ {
if _, statErr := os.Stat(destPath); statErr != nil {
break
}
filename = fmt.Sprintf("%s (%d)%s", stem, n, ext)
destPath = filepath.Join(base, filename)
}
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadTeamFile SaveFile error: %v (destPath=%s)", err, destPath)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"})
}
relPath := destPath[len("../data/"):]
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
TeamID: teamID,
Name: fh.Filename,
Type: "file",
SizeBytes: fh.Size,
StorageURL: relPath,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
parentID := c.FormValue("parent_id")
if parentID != "" {
if oid, err := primitive.ObjectIDFromHex(parentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func ListUserFiles(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := bson.M{"user_id": userID}
if parentID := c.Query("parent_id"); parentID != "" {
oid, err := primitive.ObjectIDFromHex(parentID)
if err == nil {
filter["parent_id"] = oid
}
} else {
filter["parent_id"] = bson.M{"$exists": false}
}
cursor, err := database.GetCollection("files").Find(ctx, filter,
options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"})
}
defer cursor.Close(ctx)
var files []models.File
cursor.All(ctx, &files)
if files == nil {
files = []models.File{}
}
return c.JSON(files)
}
func CreateUserFolder(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"})
}
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
UserID: userID,
Name: body.Name,
Type: "folder",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.ParentID != "" {
if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil {
file.ParentID = &oid
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func UploadUserFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
base := filepath.Join("../data/users", userID.Hex(), "files")
if err := os.MkdirAll(base, 0755); err != nil {
log.Printf("UploadUserFile MkdirAll error: %v (base=%s)", err, base)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
filename := fh.Filename
ext := filepath.Ext(filename)
stem := filename[:len(filename)-len(ext)]
destPath := filepath.Join(base, filename)
for n := 2; ; n++ {
if _, statErr := os.Stat(destPath); statErr != nil {
break
}
filename = fmt.Sprintf("%s (%d)%s", stem, n, ext)
destPath = filepath.Join(base, filename)
}
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadUserFile SaveFile error: %v (destPath=%s)", err, destPath)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"})
}
relPath := destPath[len("../data/"):]
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
UserID: userID,
Name: fh.Filename,
Type: "file",
SizeBytes: fh.Size,
StorageURL: relPath,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
parentID := c.FormValue("parent_id")
if parentID != "" {
if oid, err := primitive.ObjectIDFromHex(parentID); err == nil {
file.ParentID = &oid
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func DownloadFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fileID, err := primitive.ObjectIDFromHex(c.Params("fileId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid file ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var file models.File
if err := database.GetCollection("files").FindOne(ctx, bson.M{"_id": fileID}).Decode(&file); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found"})
}
if file.TeamID != primitive.NilObjectID {
if _, err := getTeamRole(ctx, file.TeamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
} else if file.UserID != primitive.NilObjectID {
if file.UserID != userID {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
} else {
if _, err := getProjectRole(ctx, file.ProjectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
}
if file.Type == "folder" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cannot download a folder"})
}
diskPath := filepath.Join("../data", file.StorageURL)
if _, err := os.Stat(diskPath); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found on disk"})
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Name))
return c.SendFile(diskPath)
}
func DeleteFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fileID, err := primitive.ObjectIDFromHex(c.Params("fileId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid file ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var file models.File
if err := database.GetCollection("files").FindOne(ctx, bson.M{"_id": fileID}).Decode(&file); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found"})
}
var roleFlags int
if file.TeamID != primitive.NilObjectID {
roleFlags, err = getTeamRole(ctx, file.TeamID, userID)
} else if file.UserID != primitive.NilObjectID {
if file.UserID == userID {
roleFlags = RoleOwner
} else {
err = fmt.Errorf("access denied")
}
} else {
roleFlags, err = getProjectRole(ctx, file.ProjectID, userID)
}
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("files").DeleteOne(ctx, bson.M{"_id": fileID})
if file.Type == "folder" {
database.GetCollection("files").DeleteMany(ctx, bson.M{"parent_id": fileID})
} else if file.StorageURL != "" {
os.Remove(filepath.Join("../data", file.StorageURL))
}
return c.JSON(fiber.Map{"message": "Deleted"})
}

View File

@@ -0,0 +1,111 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func createNotification(ctx context.Context, userID primitive.ObjectID, notifType, message string, projectID primitive.ObjectID, cardID primitive.ObjectID) {
n := &models.Notification{
ID: primitive.NewObjectID(),
UserID: userID,
Type: notifType,
Message: message,
ProjectID: projectID,
CardID: cardID,
Read: false,
CreatedAt: time.Now(),
}
database.GetCollection("notifications").InsertOne(ctx, n)
}
func ListNotifications(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := bson.M{"user_id": userID}
if c.Query("read") == "false" {
filter["read"] = false
}
cursor, err := database.GetCollection("notifications").Find(ctx, filter,
options.Find().SetSort(bson.M{"created_at": -1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch notifications"})
}
defer cursor.Close(ctx)
var notifications []models.Notification
cursor.All(ctx, &notifications)
if notifications == nil {
notifications = []models.Notification{}
}
return c.JSON(notifications)
}
func MarkNotificationRead(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
notifID, err := primitive.ObjectIDFromHex(c.Params("notifId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid notification ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("notifications").UpdateOne(ctx,
bson.M{"_id": notifID, "user_id": userID},
bson.M{"$set": bson.M{"read": true}},
)
return c.JSON(fiber.Map{"message": "Marked as read"})
}
func MarkAllNotificationsRead(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("notifications").UpdateMany(ctx,
bson.M{"user_id": userID, "read": false},
bson.M{"$set": bson.M{"read": true}},
)
return c.JSON(fiber.Map{"message": "All notifications marked as read"})
}
func DeleteNotification(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
notifID, err := primitive.ObjectIDFromHex(c.Params("notifId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid notification ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("notifications").DeleteOne(ctx, bson.M{"_id": notifID, "user_id": userID})
return c.JSON(fiber.Map{"message": "Notification deleted"})
}

View File

@@ -0,0 +1,633 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func getProjectRole(ctx context.Context, projectID, userID primitive.ObjectID) (int, error) {
var pm models.ProjectMember
err := database.GetCollection("project_members").FindOne(ctx, bson.M{
"project_id": projectID,
"user_id": userID,
}).Decode(&pm)
if err == nil {
return pm.RoleFlags, nil
}
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return 0, err
}
if project.TeamID == primitive.NilObjectID {
return 0, fiber.ErrForbidden
}
return getTeamRole(ctx, project.TeamID, userID)
}
func ListProjects(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
type ProjectResponse struct {
ID primitive.ObjectID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
TeamID primitive.ObjectID `json:"team_id"`
TeamName string `json:"team_name"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
IsPublic bool `json:"is_public"`
IsArchived bool `json:"is_archived"`
UpdatedAt time.Time `json:"updated_at"`
}
result := []ProjectResponse{}
cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"user_id": userID})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch teams"})
}
defer cursor.Close(ctx)
var memberships []models.TeamMember
cursor.All(ctx, &memberships)
for _, m := range memberships {
var team models.Team
database.GetCollection("teams").FindOne(ctx, bson.M{"_id": m.TeamID}).Decode(&team)
projCursor, err := database.GetCollection("projects").Find(ctx, bson.M{"team_id": m.TeamID})
if err != nil {
continue
}
var projects []models.Project
projCursor.All(ctx, &projects)
projCursor.Close(ctx)
for _, p := range projects {
roleFlags := m.RoleFlags
var pm models.ProjectMember
if err := database.GetCollection("project_members").FindOne(ctx, bson.M{
"project_id": p.ID,
"user_id": userID,
}).Decode(&pm); err == nil {
roleFlags = pm.RoleFlags
}
result = append(result, ProjectResponse{
ID: p.ID,
Name: p.Name,
Description: p.Description,
TeamID: p.TeamID,
TeamName: team.Name,
RoleFlags: roleFlags,
RoleName: roleName(roleFlags),
IsPublic: p.IsPublic,
IsArchived: p.IsArchived,
UpdatedAt: p.UpdatedAt,
})
}
}
personalCursor, err := database.GetCollection("project_members").Find(ctx, bson.M{
"user_id": userID,
})
if err == nil {
defer personalCursor.Close(ctx)
var pms []models.ProjectMember
personalCursor.All(ctx, &pms)
for _, pm := range pms {
var p models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{
"_id": pm.ProjectID,
"team_id": primitive.NilObjectID,
}).Decode(&p); err != nil {
continue
}
result = append(result, ProjectResponse{
ID: p.ID,
Name: p.Name,
Description: p.Description,
TeamID: p.TeamID,
TeamName: "Personal",
RoleFlags: pm.RoleFlags,
RoleName: roleName(pm.RoleFlags),
IsPublic: p.IsPublic,
IsArchived: p.IsArchived,
UpdatedAt: p.UpdatedAt,
})
}
}
return c.JSON(result)
}
func CreatePersonalProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Project name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
now := time.Now()
project := &models.Project{
ID: primitive.NewObjectID(),
TeamID: primitive.NilObjectID,
Name: body.Name,
Description: body.Description,
IsPublic: body.IsPublic,
IsArchived: false,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := database.GetCollection("projects").InsertOne(ctx, project); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create project"})
}
member := &models.ProjectMember{
ID: primitive.NewObjectID(),
ProjectID: project.ID,
UserID: userID,
RoleFlags: RoleOwner,
AddedAt: now,
}
database.GetCollection("project_members").InsertOne(ctx, member)
defaultColumns := []string{"To Do", "In Progress", "Done"}
for i, title := range defaultColumns {
col := &models.BoardColumn{
ID: primitive.NewObjectID(),
ProjectID: project.ID,
Title: title,
Position: i,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("board_columns").InsertOne(ctx, col)
}
return c.Status(fiber.StatusCreated).JSON(project)
}
func ListTeamProjects(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
teamRole, err := getTeamRole(ctx, teamID, userID)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("projects").Find(ctx, bson.M{"team_id": teamID},
options.Find().SetSort(bson.M{"updated_at": -1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch projects"})
}
defer cursor.Close(ctx)
var projects []models.Project
cursor.All(ctx, &projects)
type ProjectResponse struct {
models.Project
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
}
result := []ProjectResponse{}
for _, p := range projects {
flags := teamRole
var pm models.ProjectMember
if err := database.GetCollection("project_members").FindOne(ctx, bson.M{
"project_id": p.ID, "user_id": userID,
}).Decode(&pm); err == nil {
flags = pm.RoleFlags
}
result = append(result, ProjectResponse{Project: p, RoleFlags: flags, RoleName: roleName(flags)})
}
return c.JSON(result)
}
func CreateProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Project name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
project := &models.Project{
ID: primitive.NewObjectID(),
TeamID: teamID,
Name: body.Name,
Description: body.Description,
IsPublic: body.IsPublic,
IsArchived: false,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := database.GetCollection("projects").InsertOne(ctx, project); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create project"})
}
defaultColumns := []string{"To Do", "In Progress", "Done"}
for i, title := range defaultColumns {
col := &models.BoardColumn{
ID: primitive.NewObjectID(),
ProjectID: project.ID,
Title: title,
Position: i,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("board_columns").InsertOne(ctx, col)
}
return c.Status(fiber.StatusCreated).JSON(project)
}
func GetProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Project not found"})
}
return c.JSON(fiber.Map{
"id": project.ID,
"team_id": project.TeamID,
"name": project.Name,
"description": project.Description,
"visibility": project.Visibility,
"is_public": project.IsPublic,
"is_archived": project.IsArchived,
"role_flags": roleFlags,
"role_name": roleName(roleFlags),
"created_at": project.CreatedAt,
"updated_at": project.UpdatedAt,
})
}
func UpdateProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic *bool `json:"is_public"`
Visibility string `json:"visibility"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.Description != "" {
update["description"] = body.Description
}
if body.IsPublic != nil {
update["is_public"] = *body.IsPublic
}
if body.Visibility != "" {
update["visibility"] = body.Visibility
update["is_public"] = body.Visibility == "public"
}
col := database.GetCollection("projects")
col.UpdateOne(ctx, bson.M{"_id": projectID}, bson.M{"$set": update})
var project models.Project
col.FindOne(ctx, bson.M{"_id": projectID}).Decode(&project)
return c.JSON(project)
}
func ArchiveProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Project not found"})
}
database.GetCollection("projects").UpdateOne(ctx, bson.M{"_id": projectID}, bson.M{"$set": bson.M{
"is_archived": !project.IsArchived,
"updated_at": time.Now(),
}})
return c.JSON(fiber.Map{"is_archived": !project.IsArchived})
}
func DeleteProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleOwner) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Only owners can delete projects"})
}
database.GetCollection("projects").DeleteOne(ctx, bson.M{"_id": projectID})
database.GetCollection("board_columns").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("cards").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("project_members").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("events").DeleteMany(ctx, bson.M{"scope_id": projectID, "scope": "project"})
database.GetCollection("files").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("webhooks").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("whiteboards").DeleteMany(ctx, bson.M{"project_id": projectID})
return c.JSON(fiber.Map{"message": "Project deleted"})
}
func ListProjectMembers(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("project_members").Find(ctx, bson.M{"project_id": projectID})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch members"})
}
defer cursor.Close(ctx)
var members []models.ProjectMember
cursor.All(ctx, &members)
type MemberResponse struct {
UserID primitive.ObjectID `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
}
result := []MemberResponse{}
for _, m := range members {
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": m.UserID}).Decode(&user); err != nil {
continue
}
result = append(result, MemberResponse{
UserID: m.UserID,
Name: user.Name,
Email: user.Email,
RoleFlags: m.RoleFlags,
RoleName: roleName(m.RoleFlags),
})
}
return c.JSON(result)
}
func AddProjectMember(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
UserID string `json:"user_id"`
RoleFlags int `json:"role_flags"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
targetUserID, err := primitive.ObjectIDFromHex(body.UserID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user_id"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
flags := body.RoleFlags
if flags == 0 {
flags = RoleViewer
}
member := &models.ProjectMember{
ID: primitive.NewObjectID(),
ProjectID: projectID,
UserID: targetUserID,
RoleFlags: flags,
AddedAt: time.Now(),
}
database.GetCollection("project_members").InsertOne(ctx, member)
return c.Status(fiber.StatusCreated).JSON(member)
}
func UpdateProjectMemberRole(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
var body struct {
RoleFlags int `json:"role_flags"`
}
c.BodyParser(&body)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("project_members").UpdateOne(ctx,
bson.M{"project_id": projectID, "user_id": targetUserID},
bson.M{"$set": bson.M{"role_flags": body.RoleFlags}},
)
return c.JSON(fiber.Map{"user_id": targetUserID, "role_flags": body.RoleFlags, "role_name": roleName(body.RoleFlags)})
}
func RemoveProjectMember(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("project_members").DeleteOne(ctx, bson.M{"project_id": projectID, "user_id": targetUserID})
return c.JSON(fiber.Map{"message": "Member removed"})
}

View File

@@ -0,0 +1,602 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
RoleViewer = 1
RoleEditor = 2
RoleAdmin = 4
RoleOwner = 8
)
func hasPermission(userRole, requiredRole int) bool {
return userRole >= requiredRole
}
func roleName(flags int) string {
switch {
case flags&RoleOwner != 0:
return "Owner"
case flags&RoleAdmin != 0:
return "Admin"
case flags&RoleEditor != 0:
return "Editor"
default:
return "Viewer"
}
}
func getTeamRole(ctx context.Context, teamID, userID primitive.ObjectID) (int, error) {
var member models.TeamMember
err := database.GetCollection("team_members").FindOne(ctx, bson.M{
"team_id": teamID,
"user_id": userID,
}).Decode(&member)
if err != nil {
return 0, err
}
return member.RoleFlags, nil
}
func ListTeams(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"user_id": userID})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch teams"})
}
defer cursor.Close(ctx)
var memberships []models.TeamMember
if err := cursor.All(ctx, &memberships); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to decode memberships"})
}
type TeamResponse struct {
ID primitive.ObjectID `json:"id"`
Name string `json:"name"`
WorkspaceID string `json:"workspace_id"`
MemberCount int64 `json:"member_count"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
CreatedAt time.Time `json:"created_at"`
}
result := []TeamResponse{}
for _, m := range memberships {
var team models.Team
if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": m.TeamID}).Decode(&team); err != nil {
continue
}
count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": m.TeamID})
result = append(result, TeamResponse{
ID: team.ID,
Name: team.Name,
WorkspaceID: team.WorkspaceID,
MemberCount: count,
RoleFlags: m.RoleFlags,
RoleName: roleName(m.RoleFlags),
CreatedAt: team.CreatedAt,
})
}
return c.JSON(result)
}
func CreateTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
WorkspaceID string `json:"workspace_id"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Team name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
now := time.Now()
team := &models.Team{
ID: primitive.NewObjectID(),
Name: body.Name,
WorkspaceID: body.WorkspaceID,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := database.GetCollection("teams").InsertOne(ctx, team); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create team"})
}
member := &models.TeamMember{
ID: primitive.NewObjectID(),
TeamID: team.ID,
UserID: userID,
RoleFlags: RoleOwner,
InvitedBy: userID,
JoinedAt: now,
}
if _, err := database.GetCollection("team_members").InsertOne(ctx, member); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to add team owner"})
}
return c.Status(fiber.StatusCreated).JSON(team)
}
func GetTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
var team models.Team
if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": teamID}).Decode(&team); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Team not found"})
}
count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": teamID})
return c.JSON(fiber.Map{
"id": team.ID,
"name": team.Name,
"workspace_id": team.WorkspaceID,
"avatar_url": team.AvatarURL,
"banner_url": team.BannerURL,
"member_count": count,
"role_flags": roleFlags,
"role_name": roleName(roleFlags),
"created_at": team.CreatedAt,
"updated_at": team.UpdatedAt,
})
}
func UpdateTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Name string `json:"name"`
WorkspaceID string `json:"workspace_id"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.WorkspaceID != "" {
update["workspace_id"] = body.WorkspaceID
}
col := database.GetCollection("teams")
if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": update}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"})
}
var team models.Team
col.FindOne(ctx, bson.M{"_id": teamID}).Decode(&team)
return c.JSON(team)
}
func DeleteTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleOwner) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Only owners can delete teams"})
}
database.GetCollection("teams").DeleteOne(ctx, bson.M{"_id": teamID})
database.GetCollection("team_members").DeleteMany(ctx, bson.M{"team_id": teamID})
return c.JSON(fiber.Map{"message": "Team deleted"})
}
func ListTeamMembers(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"team_id": teamID},
options.Find().SetSort(bson.M{"joined_at": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch members"})
}
defer cursor.Close(ctx)
var memberships []models.TeamMember
cursor.All(ctx, &memberships)
type MemberResponse struct {
ID primitive.ObjectID `json:"id"`
UserID primitive.ObjectID `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
JoinedAt time.Time `json:"joined_at"`
}
result := []MemberResponse{}
for _, m := range memberships {
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": m.UserID}).Decode(&user); err != nil {
continue
}
result = append(result, MemberResponse{
ID: m.ID,
UserID: m.UserID,
Name: user.Name,
Email: user.Email,
RoleFlags: m.RoleFlags,
RoleName: roleName(m.RoleFlags),
JoinedAt: m.JoinedAt,
})
}
return c.JSON(result)
}
func InviteTeamMember(c *fiber.Ctx) error {
inviterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Email string `json:"email"`
RoleFlags int `json:"role_flags"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Email == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, inviterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var invitee models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": body.Email}).Decode(&invitee); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User with that email not found"})
}
existing := database.GetCollection("team_members").FindOne(ctx, bson.M{"team_id": teamID, "user_id": invitee.ID})
if existing.Err() == nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User is already a member"})
}
flags := body.RoleFlags
if flags == 0 {
flags = RoleViewer
}
member := &models.TeamMember{
ID: primitive.NewObjectID(),
TeamID: teamID,
UserID: invitee.ID,
RoleFlags: flags,
InvitedBy: inviterID,
JoinedAt: time.Now(),
}
if _, err := database.GetCollection("team_members").InsertOne(ctx, member); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to add member"})
}
var team models.Team
if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": teamID}).Decode(&team); err == nil {
createNotification(ctx, invitee.ID, "team_invite",
"You have been invited to team \""+team.Name+"\"",
primitive.NilObjectID, primitive.NilObjectID)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "Member added successfully",
"member": fiber.Map{
"user_id": invitee.ID,
"name": invitee.Name,
"email": invitee.Email,
"role_flags": flags,
"role_name": roleName(flags),
},
})
}
func UpdateTeamMemberRole(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
var body struct {
RoleFlags int `json:"role_flags"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
if _, err := database.GetCollection("team_members").UpdateOne(ctx,
bson.M{"team_id": teamID, "user_id": targetUserID},
bson.M{"$set": bson.M{"role_flags": body.RoleFlags}},
); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update role"})
}
return c.JSON(fiber.Map{
"user_id": targetUserID,
"role_flags": body.RoleFlags,
"role_name": roleName(body.RoleFlags),
})
}
func RemoveTeamMember(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("team_members").DeleteOne(ctx, bson.M{"team_id": teamID, "user_id": targetUserID})
return c.JSON(fiber.Map{"message": "Member removed"})
}
var allowedImageExts = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
func uploadTeamImage(c *fiber.Ctx, imageType string) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
if !allowedImageExts[ext] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"})
}
dir := filepath.Join("../data/teams", teamID.Hex())
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("uploadTeamImage MkdirAll error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
existingGlob := filepath.Join(dir, imageType+".*")
if matches, _ := filepath.Glob(existingGlob); len(matches) > 0 {
for _, m := range matches {
os.Remove(m)
}
}
destPath := filepath.Join(dir, imageType+ext)
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("uploadTeamImage SaveFile error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"})
}
imageURL := fmt.Sprintf("/api/teams/%s/%s", teamID.Hex(), imageType)
field := imageType + "_url"
col := database.GetCollection("teams")
if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": bson.M{
field: imageURL,
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"})
}
var team models.Team
col.FindOne(ctx, bson.M{"_id": teamID}).Decode(&team)
count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": teamID})
return c.JSON(fiber.Map{
"id": team.ID,
"name": team.Name,
"workspace_id": team.WorkspaceID,
"avatar_url": team.AvatarURL,
"banner_url": team.BannerURL,
"member_count": count,
"role_flags": roleFlags,
"role_name": roleName(roleFlags),
"created_at": team.CreatedAt,
"updated_at": team.UpdatedAt,
})
}
func UploadTeamAvatar(c *fiber.Ctx) error {
return uploadTeamImage(c, "avatar")
}
func UploadTeamBanner(c *fiber.Ctx) error {
return uploadTeamImage(c, "banner")
}
func serveTeamImage(c *fiber.Ctx, imageType string) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
dir := filepath.Join("../data/teams", teamID.Hex())
for ext := range allowedImageExts {
p := filepath.Join(dir, imageType+ext)
if _, err := os.Stat(p); err == nil {
return c.SendFile(p)
}
}
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Image not found"})
}
func ServeTeamAvatar(c *fiber.Ctx) error {
return serveTeamImage(c, "avatar")
}
func ServeTeamBanner(c *fiber.Ctx) error {
return serveTeamImage(c, "banner")
}

View File

@@ -0,0 +1,230 @@
package handlers
import (
"context"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
"golang.org/x/crypto/bcrypt"
)
func GetMe(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
}
return c.JSON(user)
}
func UpdateMe(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.Email != "" {
update["email"] = body.Email
}
if body.AvatarURL != "" {
update["avatar_url"] = body.AvatarURL
}
col := database.GetCollection("users")
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": update}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"})
}
var user models.User
if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch updated user"})
}
return c.JSON(user)
}
func SearchUsers(c *fiber.Ctx) error {
q := c.Query("q")
if len(q) < 1 {
return c.JSON([]fiber.Map{})
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := bson.M{
"$or": bson.A{
bson.M{"name": bson.M{"$regex": q, "$options": "i"}},
bson.M{"email": bson.M{"$regex": q, "$options": "i"}},
},
}
cursor, err := database.GetCollection("users").Find(ctx, filter, options.Find().SetLimit(10))
if err != nil {
return c.JSON([]fiber.Map{})
}
defer cursor.Close(ctx)
var users []models.User
cursor.All(ctx, &users)
type UserResult struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
result := []UserResult{}
for _, u := range users {
result = append(result, UserResult{ID: u.ID.Hex(), Name: u.Name, Email: u.Email})
}
return c.JSON(result)
}
func UploadUserAvatar(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
if !allowedImageExts[ext] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"})
}
dir := filepath.Join("../data/users", userID.Hex())
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("UploadUserAvatar MkdirAll error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
existingGlob := filepath.Join(dir, "avatar.*")
if matches, _ := filepath.Glob(existingGlob); len(matches) > 0 {
for _, m := range matches {
os.Remove(m)
}
}
destPath := filepath.Join(dir, "avatar"+ext)
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadUserAvatar SaveFile error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
col := database.GetCollection("users")
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{
"avatar_url": "/api/users/me/avatar",
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"})
}
var user models.User
if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch updated user"})
}
return c.JSON(user)
}
func ServeUserAvatar(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
dir := filepath.Join("../data/users", userID.Hex())
for ext := range allowedImageExts {
p := filepath.Join(dir, "avatar"+ext)
if _, err := os.Stat(p); err == nil {
return c.SendFile(p)
}
}
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Avatar not found"})
}
func ChangePassword(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.CurrentPassword == "" || body.NewPassword == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "current_password and new_password are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
col := database.GetCollection("users")
var user models.User
if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.CurrentPassword)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Current password is incorrect"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"})
}
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{
"password_hash": string(hash),
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"})
}
return c.JSON(fiber.Map{"message": "Password updated successfully"})
}

View File

@@ -0,0 +1,220 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ListWebhooks(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("webhooks").Find(ctx,
bson.M{"project_id": projectID},
options.Find().SetSort(bson.M{"created_at": -1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch webhooks"})
}
defer cursor.Close(ctx)
var webhooks []models.Webhook
cursor.All(ctx, &webhooks)
if webhooks == nil {
webhooks = []models.Webhook{}
}
return c.JSON(webhooks)
}
func CreateWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Secret string `json:"secret"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" || body.URL == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name and url are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
wType := body.Type
if wType == "" {
wType = "custom"
}
now := time.Now()
webhook := &models.Webhook{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Name: body.Name,
Type: wType,
URL: body.URL,
Status: "active",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.Secret != "" {
webhook.SecretHash = body.Secret
}
database.GetCollection("webhooks").InsertOne(ctx, webhook)
return c.Status(fiber.StatusCreated).JSON(webhook)
}
func UpdateWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wh models.Webhook
if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"})
}
roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.URL != "" {
update["url"] = body.URL
}
if body.Type != "" {
update["type"] = body.Type
}
col := database.GetCollection("webhooks")
col.UpdateOne(ctx, bson.M{"_id": webhookID}, bson.M{"$set": update})
var updated models.Webhook
col.FindOne(ctx, bson.M{"_id": webhookID}).Decode(&updated)
return c.JSON(updated)
}
func ToggleWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wh models.Webhook
if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"})
}
roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
newStatus := "active"
if wh.Status == "active" {
newStatus = "inactive"
}
col := database.GetCollection("webhooks")
col.UpdateOne(ctx, bson.M{"_id": webhookID}, bson.M{"$set": bson.M{
"status": newStatus,
"updated_at": time.Now(),
}})
var updated models.Webhook
col.FindOne(ctx, bson.M{"_id": webhookID}).Decode(&updated)
return c.JSON(updated)
}
func DeleteWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wh models.Webhook
if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"})
}
roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("webhooks").DeleteOne(ctx, bson.M{"_id": webhookID})
return c.JSON(fiber.Map{"message": "Webhook deleted"})
}

View File

@@ -0,0 +1,96 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
func GetWhiteboard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
var wb models.Whiteboard
err = database.GetCollection("whiteboards").FindOne(ctx, bson.M{"project_id": projectID}).Decode(&wb)
if err == mongo.ErrNoDocuments {
return c.JSON(fiber.Map{"id": nil, "project_id": projectID, "data": "", "updated_at": nil})
}
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch whiteboard"})
}
return c.JSON(wb)
}
func SaveWhiteboard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Data string `json:"data"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
col := database.GetCollection("whiteboards")
var existing models.Whiteboard
err = col.FindOne(ctx, bson.M{"project_id": projectID}).Decode(&existing)
if err == mongo.ErrNoDocuments {
wb := &models.Whiteboard{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Data: body.Data,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
col.InsertOne(ctx, wb)
return c.JSON(fiber.Map{"id": wb.ID, "project_id": projectID, "updated_at": now})
}
col.UpdateOne(ctx, bson.M{"project_id": projectID}, bson.M{"$set": bson.M{
"data": body.Data,
"updated_at": now,
}})
return c.JSON(fiber.Map{"id": existing.ID, "project_id": projectID, "updated_at": now})
}

View File

@@ -0,0 +1,164 @@
package handlers
import (
"encoding/json"
"log"
"os"
"sync"
"github.com/gofiber/websocket/v2"
"github.com/golang-jwt/jwt/v5"
)
type wsMessage struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
UserID string `json:"user_id,omitempty"`
Name string `json:"name,omitempty"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
}
type wsClient struct {
conn *websocket.Conn
userID string
name string
}
type whiteboardRoom struct {
clients map[*websocket.Conn]*wsClient
mu sync.RWMutex
}
var wsRooms = struct {
m map[string]*whiteboardRoom
mu sync.RWMutex
}{m: make(map[string]*whiteboardRoom)}
func getRoom(boardID string) *whiteboardRoom {
wsRooms.mu.Lock()
defer wsRooms.mu.Unlock()
if room, ok := wsRooms.m[boardID]; ok {
return room
}
room := &whiteboardRoom{clients: make(map[*websocket.Conn]*wsClient)}
wsRooms.m[boardID] = room
return room
}
func (r *whiteboardRoom) broadcast(sender *websocket.Conn, msg []byte) {
r.mu.RLock()
defer r.mu.RUnlock()
for conn := range r.clients {
if conn != sender {
_ = conn.WriteMessage(websocket.TextMessage, msg)
}
}
}
func (r *whiteboardRoom) userList() []map[string]string {
r.mu.RLock()
defer r.mu.RUnlock()
list := make([]map[string]string, 0, len(r.clients))
for _, c := range r.clients {
list = append(list, map[string]string{"user_id": c.userID, "name": c.name})
}
return list
}
func parseWSToken(tokenStr string) (userID string, email string, ok bool) {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = "changeme-jwt-secret"
}
type claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
c := &claims{}
token, err := jwt.ParseWithClaims(tokenStr, c, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
return "", "", false
}
return c.UserID, c.Email, true
}
func WhiteboardWS(c *websocket.Conn) {
boardID := c.Params("id")
tokenStr := c.Query("token", "")
userName := c.Query("name", "Anonymous")
userID, _, ok := parseWSToken(tokenStr)
if !ok {
_ = c.WriteJSON(map[string]string{"type": "error", "payload": "unauthorized"})
_ = c.Close()
return
}
room := getRoom(boardID)
client := &wsClient{conn: c, userID: userID, name: userName}
room.mu.Lock()
room.clients[c] = client
room.mu.Unlock()
joinMsg, _ := json.Marshal(map[string]interface{}{
"type": "join",
"user_id": userID,
"name": userName,
"users": room.userList(),
})
room.broadcast(nil, joinMsg)
selfMsg, _ := json.Marshal(map[string]interface{}{
"type": "users",
"users": room.userList(),
})
_ = c.WriteMessage(websocket.TextMessage, selfMsg)
defer func() {
room.mu.Lock()
delete(room.clients, c)
empty := len(room.clients) == 0
room.mu.Unlock()
leaveMsg, _ := json.Marshal(map[string]interface{}{
"type": "leave",
"user_id": userID,
"name": userName,
"users": room.userList(),
})
room.broadcast(nil, leaveMsg)
if empty {
wsRooms.mu.Lock()
delete(wsRooms.m, boardID)
wsRooms.mu.Unlock()
}
}()
for {
_, msg, err := c.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
log.Printf("WS error board=%s user=%s: %v", boardID, userID, err)
}
break
}
var incoming map[string]interface{}
if json.Unmarshal(msg, &incoming) == nil {
incoming["user_id"] = userID
incoming["name"] = userName
outMsg, _ := json.Marshal(incoming)
room.broadcast(c, outMsg)
}
}
}

View File

@@ -0,0 +1,51 @@
package middleware
import (
"os"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
type JWTClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
func Protected() fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization header"})
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid authorization header format"})
}
tokenStr := parts[1]
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = "changeme-jwt-secret"
}
claims := &JWTClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fiber.ErrUnauthorized
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
}
c.Locals("user_id", claims.UserID)
c.Locals("user_email", claims.Email)
return c.Next()
}
}

View File

@@ -0,0 +1,184 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash" json:"-"`
AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Team struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"`
BannerURL string `bson:"banner_url,omitempty" json:"banner_url,omitempty"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type TeamMember struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
RoleFlags int `bson:"role_flags" json:"role_flags"`
InvitedBy primitive.ObjectID `bson:"invited_by" json:"invited_by"`
JoinedAt time.Time `bson:"joined_at" json:"joined_at"`
}
type Project struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Visibility string `bson:"visibility" json:"visibility"`
IsPublic bool `bson:"is_public" json:"is_public"`
IsArchived bool `bson:"is_archived" json:"is_archived"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type ProjectMember struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
RoleFlags int `bson:"role_flags" json:"role_flags"`
AddedAt time.Time `bson:"added_at" json:"added_at"`
}
type BoardColumn struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Title string `bson:"title" json:"title"`
Position int `bson:"position" json:"position"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Subtask struct {
ID int `bson:"id" json:"id"`
Text string `bson:"text" json:"text"`
Done bool `bson:"done" json:"done"`
}
type Card struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Title string `bson:"title" json:"title"`
Description string `bson:"description" json:"description"`
Priority string `bson:"priority" json:"priority"`
Color string `bson:"color" json:"color"`
DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
Assignees []string `bson:"assignees" json:"assignees"`
Subtasks []Subtask `bson:"subtasks" json:"subtasks"`
Position int `bson:"position" json:"position"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Event struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Title string `bson:"title" json:"title"`
Date string `bson:"date" json:"date"`
Time string `bson:"time" json:"time"`
Color string `bson:"color" json:"color"`
Description string `bson:"description" json:"description"`
Scope string `bson:"scope" json:"scope"`
ScopeID primitive.ObjectID `bson:"scope_id" json:"scope_id"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Notification struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
Type string `bson:"type" json:"type"`
Message string `bson:"message" json:"message"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
CardID primitive.ObjectID `bson:"card_id,omitempty" json:"card_id,omitempty"`
Read bool `bson:"read" json:"read"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type Doc struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
Title string `bson:"title" json:"title"`
Content string `bson:"content" json:"content"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type File struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id,omitempty" json:"project_id,omitempty"`
TeamID primitive.ObjectID `bson:"team_id,omitempty" json:"team_id,omitempty"`
UserID primitive.ObjectID `bson:"user_id,omitempty" json:"user_id,omitempty"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
SizeBytes int64 `bson:"size_bytes" json:"size_bytes"`
ParentID *primitive.ObjectID `bson:"parent_id,omitempty" json:"parent_id,omitempty"`
StorageURL string `bson:"storage_url,omitempty" json:"storage_url,omitempty"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Webhook struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
URL string `bson:"url" json:"url"`
SecretHash string `bson:"secret_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
LastTriggered *time.Time `bson:"last_triggered,omitempty" json:"last_triggered,omitempty"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Whiteboard struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Data string `bson:"data" json:"data"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type APIKey struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
Name string `bson:"name" json:"name"`
Scopes []string `bson:"scopes" json:"scopes"`
KeyHash string `bson:"key_hash" json:"-"`
Prefix string `bson:"prefix" json:"prefix"`
LastUsed *time.Time `bson:"last_used,omitempty" json:"last_used,omitempty"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type ChatMessage struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
UserName string `bson:"user_name" json:"user_name"`
Content string `bson:"content" json:"content"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

99
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,99 @@
const BASE = '/api';
let accessToken: string | null =
typeof localStorage !== 'undefined' ? localStorage.getItem('access_token') : null;
export function getAccessToken() {
return accessToken;
}
export function setAccessToken(token: string | null) {
accessToken = token;
if (typeof localStorage !== 'undefined') {
if (token) {
localStorage.setItem('access_token', token);
} else {
localStorage.removeItem('access_token');
}
}
}
async function refreshAccessToken(): Promise<string | null> {
const refreshToken =
typeof localStorage !== 'undefined' ? localStorage.getItem('refresh_token') : null;
if (!refreshToken) return null;
const res = await fetch(`${BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!res.ok) {
setAccessToken(null);
if (typeof localStorage !== 'undefined') localStorage.removeItem('refresh_token');
return null;
}
const data = await res.json();
setAccessToken(data.access_token);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('refresh_token', data.refresh_token);
}
return data.access_token;
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
retry = true
): Promise<T> {
const token = accessToken;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>)
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 401 && retry) {
const newToken = await refreshAccessToken();
if (newToken) return apiFetch<T>(path, options, false);
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export async function apiFetchFormData<T>(
path: string,
formData: FormData,
retry = true
): Promise<T> {
const token = accessToken;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method: 'POST', body: formData, headers });
if (res.status === 401 && retry) {
const newToken = await refreshAccessToken();
if (newToken) return apiFetchFormData<T>(path, formData, false);
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}

371
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,371 @@
import { apiFetch, apiFetchFormData } from './client';
import type {
AuthResponse,
User,
Team,
TeamMember,
Project,
ProjectMember,
BoardData,
Column,
Card,
Event,
Notification,
Doc,
FileItem,
Webhook,
Whiteboard,
ApiKey,
ApiKeyCreated,
ChatMessage
} from '$lib/types/api';
export const auth = {
register: (name: string, email: string, password: string) =>
apiFetch<AuthResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify({ name, email, password })
}),
login: (email: string, password: string) =>
apiFetch<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
logout: () => apiFetch<void>('/auth/logout', { method: 'POST' })
};
export const users = {
me: () => apiFetch<User>('/users/me'),
updateMe: (data: Partial<Pick<User, 'name' | 'email' | 'avatar_url'>>) =>
apiFetch<User>('/users/me', { method: 'PUT', body: JSON.stringify(data) }),
changePassword: (current_password: string, new_password: string) =>
apiFetch<void>('/users/me/password', {
method: 'PUT',
body: JSON.stringify({ current_password, new_password })
}),
search: (q: string) =>
apiFetch<{ id: string; name: string; email: string }[]>(`/users/search?q=${encodeURIComponent(q)}`),
listFiles: (parentId = '') => {
const qs = parentId ? `?parent_id=${encodeURIComponent(parentId)}` : '';
return apiFetch<FileItem[]>(`/users/me/files${qs}`);
},
createFolder: (name: string, parent_id = '') =>
apiFetch<FileItem>('/users/me/files/folder', {
method: 'POST',
body: JSON.stringify({ name, parent_id })
}),
uploadFile: (file: File, parent_id = '') => {
const fd = new FormData();
fd.append('file', file);
if (parent_id) fd.append('parent_id', parent_id);
return apiFetchFormData<FileItem>('/users/me/files/upload', fd);
},
uploadAvatar: (file: File) => {
const fd = new FormData();
fd.append('file', file);
return apiFetchFormData<User>('/users/me/avatar', fd);
}
};
export const teams = {
list: () => apiFetch<Team[]>('/teams'),
create: (name: string) =>
apiFetch<Team>('/teams', { method: 'POST', body: JSON.stringify({ name }) }),
get: (teamId: string) => apiFetch<Team>(`/teams/${teamId}`),
update: (teamId: string, data: Partial<Pick<Team, 'name'>>) =>
apiFetch<Team>(`/teams/${teamId}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (teamId: string) => apiFetch<void>(`/teams/${teamId}`, { method: 'DELETE' }),
listMembers: (teamId: string) => apiFetch<TeamMember[]>(`/teams/${teamId}/members`),
invite: (teamId: string, email: string, role_flags: number) =>
apiFetch<{ message: string; member: TeamMember }>(`/teams/${teamId}/members/invite`, {
method: 'POST',
body: JSON.stringify({ email, role_flags })
}),
updateMemberRole: (teamId: string, userId: string, role_flags: number) =>
apiFetch<{ user_id: string; role_flags: number; role_name: string }>(
`/teams/${teamId}/members/${userId}`,
{ method: 'PUT', body: JSON.stringify({ role_flags }) }
),
removeMember: (teamId: string, userId: string) =>
apiFetch<void>(`/teams/${teamId}/members/${userId}`, { method: 'DELETE' }),
listProjects: (teamId: string) => apiFetch<Project[]>(`/teams/${teamId}/projects`),
createProject: (teamId: string, name: string, description: string) =>
apiFetch<Project>(`/teams/${teamId}/projects`, {
method: 'POST',
body: JSON.stringify({ name, description })
}),
listEvents: (teamId: string) => apiFetch<Event[]>(`/teams/${teamId}/events`),
createEvent: (
teamId: string,
data: Pick<Event, 'title' | 'description' | 'date' | 'time' | 'color'>
) =>
apiFetch<Event>(`/teams/${teamId}/events`, { method: 'POST', body: JSON.stringify(data) }),
listDocs: (teamId: string) => apiFetch<Doc[]>(`/teams/${teamId}/docs`),
createDoc: (teamId: string, title: string, content: string) =>
apiFetch<Doc>(`/teams/${teamId}/docs`, {
method: 'POST',
body: JSON.stringify({ title, content })
}),
listFiles: (teamId: string, parentId = '') => {
const qs = parentId ? `?parent_id=${encodeURIComponent(parentId)}` : '';
return apiFetch<FileItem[]>(`/teams/${teamId}/files${qs}`);
},
createFolder: (teamId: string, name: string, parent_id = '') =>
apiFetch<FileItem>(`/teams/${teamId}/files/folder`, {
method: 'POST',
body: JSON.stringify({ name, parent_id })
}),
uploadFile: (teamId: string, file: File, parent_id = '') => {
const fd = new FormData();
fd.append('file', file);
if (parent_id) fd.append('parent_id', parent_id);
return apiFetchFormData<FileItem>(`/teams/${teamId}/files/upload`, fd);
},
uploadAvatar: (teamId: string, file: File) => {
const fd = new FormData();
fd.append('file', file);
return apiFetchFormData<Team>(`/teams/${teamId}/avatar`, fd);
},
uploadBanner: (teamId: string, file: File) => {
const fd = new FormData();
fd.append('file', file);
return apiFetchFormData<Team>(`/teams/${teamId}/banner`, fd);
},
listChatMessages: (teamId: string, before?: string) => {
const qs = before ? `?before=${encodeURIComponent(before)}` : '';
return apiFetch<ChatMessage[]>(`/teams/${teamId}/chat${qs}`);
}
};
export const projects = {
list: () => apiFetch<Project[]>('/projects'),
createPersonal: (name: string, description: string) =>
apiFetch<Project>('/projects', {
method: 'POST',
body: JSON.stringify({ name, description })
}),
get: (projectId: string) => apiFetch<Project>(`/projects/${projectId}`),
update: (projectId: string, data: Partial<Pick<Project, 'name' | 'description' | 'visibility'>>) =>
apiFetch<Project>(`/projects/${projectId}`, { method: 'PUT', body: JSON.stringify(data) }),
archive: (projectId: string) =>
apiFetch<Project>(`/projects/${projectId}/archive`, { method: 'PUT' }),
delete: (projectId: string) => apiFetch<void>(`/projects/${projectId}`, { method: 'DELETE' }),
listMembers: (projectId: string) => apiFetch<ProjectMember[]>(`/projects/${projectId}/members`),
addMember: (projectId: string, userId: string, role_flags: number) =>
apiFetch<ProjectMember>(`/projects/${projectId}/members`, {
method: 'POST',
body: JSON.stringify({ user_id: userId, role_flags })
}),
updateMemberRole: (projectId: string, userId: string, role_flags: number) =>
apiFetch<ProjectMember>(`/projects/${projectId}/members/${userId}`, {
method: 'PUT',
body: JSON.stringify({ role_flags })
}),
removeMember: (projectId: string, userId: string) =>
apiFetch<void>(`/projects/${projectId}/members/${userId}`, { method: 'DELETE' }),
listEvents: (projectId: string) => apiFetch<Event[]>(`/projects/${projectId}/events`),
createEvent: (
projectId: string,
data: Pick<Event, 'title' | 'description' | 'date' | 'time' | 'color'>
) =>
apiFetch<Event>(`/projects/${projectId}/events`, {
method: 'POST',
body: JSON.stringify(data)
}),
listFiles: (projectId: string, parentId = '') => {
const qs = parentId ? `?parent_id=${encodeURIComponent(parentId)}` : '';
return apiFetch<FileItem[]>(`/projects/${projectId}/files${qs}`);
},
createFolder: (projectId: string, name: string, parent_id = '') =>
apiFetch<FileItem>(`/projects/${projectId}/files/folder`, {
method: 'POST',
body: JSON.stringify({ name, parent_id })
}),
uploadFile: (projectId: string, file: File, parent_id = '') => {
const fd = new FormData();
fd.append('file', file);
if (parent_id) fd.append('parent_id', parent_id);
return apiFetchFormData<FileItem>(`/projects/${projectId}/files/upload`, fd);
},
listWebhooks: (projectId: string) => apiFetch<Webhook[]>(`/projects/${projectId}/webhooks`),
createWebhook: (projectId: string, data: Pick<Webhook, 'name' | 'url' | 'type'>) =>
apiFetch<Webhook>(`/projects/${projectId}/webhooks`, {
method: 'POST',
body: JSON.stringify(data)
}),
getWhiteboard: (projectId: string) =>
apiFetch<Whiteboard>(`/projects/${projectId}/whiteboard`),
saveWhiteboard: (projectId: string, data: string) =>
apiFetch<Whiteboard>(`/projects/${projectId}/whiteboard`, {
method: 'PUT',
body: JSON.stringify({ data })
})
};
export const board = {
get: (projectId: string) => apiFetch<BoardData>(`/projects/${projectId}/board`),
createColumn: (projectId: string, title: string) =>
apiFetch<Column>(`/projects/${projectId}/columns`, {
method: 'POST',
body: JSON.stringify({ title })
}),
updateColumn: (projectId: string, columnId: string, title: string) =>
apiFetch<Column>(`/projects/${projectId}/columns/${columnId}`, {
method: 'PUT',
body: JSON.stringify({ title })
}),
reorderColumn: (projectId: string, columnId: string, position: number) =>
apiFetch<{ id: string; position: number }>(
`/projects/${projectId}/columns/${columnId}/position`,
{ method: 'PUT', body: JSON.stringify({ position }) }
),
deleteColumn: (projectId: string, columnId: string) =>
apiFetch<void>(`/projects/${projectId}/columns/${columnId}`, { method: 'DELETE' }),
createCard: (
projectId: string,
columnId: string,
data: Pick<Card, 'title' | 'description' | 'priority' | 'color' | 'due_date' | 'assignees'>
) =>
apiFetch<Card>(`/projects/${projectId}/columns/${columnId}/cards`, {
method: 'POST',
body: JSON.stringify(data)
})
};
export const cards = {
update: (
cardId: string,
data: Partial<Pick<Card, 'title' | 'description' | 'priority' | 'color' | 'due_date' | 'assignees' | 'subtasks'>>
) => apiFetch<Card>(`/cards/${cardId}`, { method: 'PUT', body: JSON.stringify(data) }),
move: (cardId: string, column_id: string, position: number) =>
apiFetch<Card>(`/cards/${cardId}/move`, {
method: 'PUT',
body: JSON.stringify({ column_id, position })
}),
delete: (cardId: string) => apiFetch<void>(`/cards/${cardId}`, { method: 'DELETE' })
};
export const events = {
update: (
eventId: string,
data: Partial<Pick<Event, 'title' | 'description' | 'date' | 'time' | 'color'>>
) => apiFetch<Event>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (eventId: string) => apiFetch<void>(`/events/${eventId}`, { method: 'DELETE' })
};
export const notifications = {
list: () => apiFetch<Notification[]>('/notifications'),
markRead: (notifId: string) =>
apiFetch<Notification>(`/notifications/${notifId}/read`, { method: 'PUT' }),
markAllRead: () => apiFetch<void>('/notifications/read-all', { method: 'PUT' }),
delete: (notifId: string) => apiFetch<void>(`/notifications/${notifId}`, { method: 'DELETE' })
};
export const docs = {
get: (docId: string) => apiFetch<Doc>(`/docs/${docId}`),
update: (docId: string, data: Partial<Pick<Doc, 'title' | 'content'>>) =>
apiFetch<Doc>(`/docs/${docId}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (docId: string) => apiFetch<void>(`/docs/${docId}`, { method: 'DELETE' })
};
export const files = {
delete: (fileId: string) => apiFetch<void>(`/files/${fileId}`, { method: 'DELETE' }),
downloadUrl: (fileId: string) => `/api/files/${fileId}/download`,
download: async (fileId: string, fileName: string) => {
const { getAccessToken } = await import('./client');
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/files/${fileId}/download`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
};
export const webhooks = {
update: (webhookId: string, data: Partial<Pick<Webhook, 'name' | 'url' | 'type'>>) =>
apiFetch<Webhook>(`/webhooks/${webhookId}`, { method: 'PUT', body: JSON.stringify(data) }),
toggle: (webhookId: string) =>
apiFetch<Webhook>(`/webhooks/${webhookId}/toggle`, { method: 'PUT' }),
delete: (webhookId: string) => apiFetch<void>(`/webhooks/${webhookId}`, { method: 'DELETE' })
};
export const apiKeys = {
list: () => apiFetch<ApiKey[]>('/users/me/api-keys'),
create: (name: string, scopes: string[]) =>
apiFetch<ApiKeyCreated>('/users/me/api-keys', {
method: 'POST',
body: JSON.stringify({ name, scopes })
}),
revoke: (keyId: string) => apiFetch<void>(`/users/me/api-keys/${keyId}`, { method: 'DELETE' })
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import Modal from '$lib/components/Modal/Modal.svelte';
let { events = [], onEventClick = null as ((event: any) => void) | null } = $props();
let currentDate = $state(new Date());
let viewMode = $state<'month' | 'week'>('month');
let selectedEvent = $state<any | null>(null);
let isEventModalOpen = $state(false);
let daysInMonth = $derived(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate());
let firstDayOfMonth = $derived(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getDay());
let weekStart = $derived(new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() - currentDate.getDay()));
let weekDays = $derived(Array.from({length: 7}, (_, i) => new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + i)));
const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const dayNamesFull = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
function prev() {
if (viewMode === 'month') {
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
} else {
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() - 7);
}
}
function next() {
if (viewMode === 'month') {
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
} else {
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() + 7);
}
}
function isSameDay(d1: Date, d2: Date) {
return d1.getDate() === d2.getDate() && d1.getMonth() === d2.getMonth() && d1.getFullYear() === d2.getFullYear();
}
function getEventsForDate(d: Date) {
return events.filter((e: any) => {
if (!e.date) return false;
const parts = e.date.split('-');
const eventDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
return isSameDay(eventDate, d);
});
}
function openEvent(event: any) {
selectedEvent = event;
isEventModalOpen = true;
if (onEventClick) onEventClick(event);
}
const colorDot: Record<string, string> = {
red: 'bg-red-500',
blue: 'bg-blue-500',
green: 'bg-green-500',
purple: 'bg-purple-500',
yellow: 'bg-yellow-500',
neutral: 'bg-neutral-500',
orange: 'bg-orange-500'
};
const colorBadge: Record<string, string> = {
red: 'bg-red-500/15 text-red-300 border-red-500/25',
blue: 'bg-blue-500/15 text-blue-300 border-blue-500/25',
green: 'bg-green-500/15 text-green-300 border-green-500/25',
purple: 'bg-purple-500/15 text-purple-300 border-purple-500/25',
yellow: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/25',
neutral: 'bg-neutral-600/30 text-neutral-300 border-neutral-600/50',
orange: 'bg-orange-500/15 text-orange-300 border-orange-500/25'
};
const colorFull: Record<string, string> = {
red: 'bg-red-500/20 border-red-500/40 text-red-200',
blue: 'bg-blue-500/20 border-blue-500/40 text-blue-200',
green: 'bg-green-500/20 border-green-500/40 text-green-200',
purple: 'bg-purple-500/20 border-purple-500/40 text-purple-200',
yellow: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-200',
neutral: 'bg-neutral-600/30 border-neutral-600/50 text-neutral-300',
orange: 'bg-orange-500/20 border-orange-500/40 text-orange-200'
};
function formatDate(dateStr: string) {
if (!dateStr) return '';
const parts = dateStr.split('-');
const d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
return d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
</script>
<div class="bg-neutral-800 rounded-xl shadow-lg border border-neutral-700 flex flex-col">
<div class="px-5 py-4 border-b border-neutral-700 flex flex-wrap gap-3 items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<button
onclick={prev}
class="w-8 h-8 flex items-center justify-center text-neutral-400 hover:text-white hover:bg-neutral-700 rounded-lg transition-colors"
>
<Icon icon="lucide:chevron-left" class="w-4 h-4" />
</button>
<button
onclick={next}
class="w-8 h-8 flex items-center justify-center text-neutral-400 hover:text-white hover:bg-neutral-700 rounded-lg transition-colors"
>
<Icon icon="lucide:chevron-right" class="w-4 h-4" />
</button>
</div>
<h2 class="text-lg font-semibold text-white min-w-[200px]">
{#if viewMode === 'month'}
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
{:else}
{monthNames[weekStart.getMonth()].slice(0,3)} {weekStart.getDate()}
{monthNames[weekDays[6].getMonth()].slice(0,3)} {weekDays[6].getDate()}, {weekDays[6].getFullYear()}
{/if}
</h2>
<button
onclick={() => currentDate = new Date()}
class="hidden sm:block text-xs font-medium text-neutral-400 hover:text-white bg-neutral-700/60 hover:bg-neutral-700 px-3 py-1.5 rounded-lg transition-colors border border-neutral-600"
>
Today
</button>
</div>
<div class="flex bg-neutral-900 rounded-lg p-0.5 border border-neutral-700">
<button
onclick={() => viewMode = 'month'}
class="px-4 py-1.5 text-xs font-semibold rounded-md transition-all {viewMode === 'month' ? 'bg-neutral-700 text-white shadow-sm' : 'text-neutral-400 hover:text-white'}"
>
Month
</button>
<button
onclick={() => viewMode = 'week'}
class="px-4 py-1.5 text-xs font-semibold rounded-md transition-all {viewMode === 'week' ? 'bg-neutral-700 text-white shadow-sm' : 'text-neutral-400 hover:text-white'}"
>
Week
</button>
</div>
</div>
{#if viewMode === 'month'}
<div class="grid grid-cols-7 border-b border-neutral-700 shrink-0">
{#each dayNames as day}
<div class="py-2.5 text-center text-[11px] font-bold text-neutral-500 uppercase tracking-widest border-r border-neutral-700/50 last:border-r-0">
{day}
</div>
{/each}
</div>
<div class="grid grid-cols-7 bg-neutral-700/30 gap-px" style="grid-auto-rows: minmax(110px, auto);">
{#each Array(firstDayOfMonth) as _}
<div class="bg-neutral-800/40 p-2"></div>
{/each}
{#each Array(daysInMonth) as _, i}
{@const dayNum = i + 1}
{@const cellDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), dayNum)}
{@const dayEvents = getEventsForDate(cellDate)}
{@const isToday = isSameDay(cellDate, new Date())}
{@const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6}
<div class="bg-neutral-800 p-2 flex flex-col group hover:bg-neutral-750 transition-colors {isWeekend ? 'bg-neutral-800/70' : ''}">
<div class="flex justify-end mb-1.5">
<span class="text-xs font-semibold w-6 h-6 flex items-center justify-center rounded-full transition-colors
{isToday ? 'bg-blue-600 text-white' : 'text-neutral-400 group-hover:text-neutral-200'}">
{dayNum}
</span>
</div>
<div class="flex-1 space-y-0.5 overflow-hidden">
{#each dayEvents.slice(0, 3) as event}
<button
onclick={() => openEvent(event)}
class="w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-[11px] font-medium text-left truncate border transition-colors hover:brightness-110 {colorBadge[event.color || 'neutral']}"
>
<span class="w-1.5 h-1.5 rounded-full shrink-0 {colorDot[event.color || 'neutral']}"></span>
<span class="truncate">{event.title}</span>
</button>
{/each}
{#if dayEvents.length > 3}
<button
onclick={() => openEvent(dayEvents[3])}
class="w-full text-left px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 hover:text-neutral-300 transition-colors"
>
+{dayEvents.length - 3} more
</button>
{/if}
</div>
</div>
{/each}
{#each Array((7 - ((firstDayOfMonth + daysInMonth) % 7)) % 7) as _}
<div class="bg-neutral-800/40 p-2"></div>
{/each}
</div>
{:else}
<div class="grid grid-cols-7 border-b border-neutral-700 shrink-0">
{#each weekDays as day}
{@const isToday = isSameDay(day, new Date())}
<div class="py-3 text-center border-r border-neutral-700/50 last:border-r-0 {isToday ? 'bg-blue-600/5' : ''}">
<div class="text-[10px] font-bold uppercase tracking-widest {isToday ? 'text-blue-400' : 'text-neutral-500'}">{dayNames[day.getDay()]}</div>
<div class="text-xl font-bold mt-0.5 {isToday ? 'text-blue-400' : 'text-neutral-300'}">{day.getDate()}</div>
{#if isToday}
<div class="w-1 h-1 rounded-full bg-blue-500 mx-auto mt-1"></div>
{/if}
</div>
{/each}
</div>
<div class="grid grid-cols-7 gap-px bg-neutral-700/30" style="min-height: 400px;">
{#each weekDays as day}
{@const isToday = isSameDay(day, new Date())}
{@const dayEvents = getEventsForDate(day)}
<div class="bg-neutral-800 p-2 overflow-y-auto space-y-1.5 {isToday ? 'bg-blue-600/5' : ''}">
{#each dayEvents as event}
<button
onclick={() => openEvent(event)}
class="w-full text-left p-2.5 rounded-lg border shadow-sm transition-all hover:brightness-110 hover:shadow-md flex flex-col gap-0.5 {colorFull[event.color || 'neutral']}"
>
{#if event.time}
<div class="text-[10px] font-bold uppercase tracking-wider opacity-70 flex items-center gap-1">
<Icon icon="lucide:clock" class="w-2.5 h-2.5" />
{event.time}
</div>
{/if}
<div class="text-xs font-semibold leading-snug">{event.title}</div>
{#if event.description}
<div class="text-[10px] opacity-60 line-clamp-2 mt-0.5">{event.description}</div>
{/if}
</button>
{/each}
{#if dayEvents.length === 0}
<div class="h-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pt-4">
<span class="text-xs text-neutral-600">No events</span>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if selectedEvent}
<Modal bind:isOpen={isEventModalOpen} title="Event Details">
<div class="space-y-4">
<div class="flex items-start gap-3">
<span class="w-3 h-3 rounded-full mt-1 shrink-0 {colorDot[selectedEvent.color || 'neutral']}"></span>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white leading-snug">{selectedEvent.title}</h3>
{#if selectedEvent.date}
<p class="text-sm text-neutral-400 mt-1 flex items-center gap-1.5">
<Icon icon="lucide:calendar" class="w-3.5 h-3.5" />
{formatDate(selectedEvent.date)}
</p>
{/if}
{#if selectedEvent.time}
<p class="text-sm text-neutral-400 mt-0.5 flex items-center gap-1.5">
<Icon icon="lucide:clock" class="w-3.5 h-3.5" />
{selectedEvent.time}
</p>
{/if}
</div>
</div>
{#if selectedEvent.description}
<div class="bg-neutral-700/50 rounded-lg p-4 border border-neutral-600/50">
<p class="text-sm text-neutral-300 leading-relaxed">{selectedEvent.description}</p>
</div>
{/if}
<div class="flex justify-end pt-2 border-t border-neutral-700">
<button
onclick={() => isEventModalOpen = false}
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm"
>
Close
</button>
</div>
</div>
</Modal>
{/if}

View File

@@ -0,0 +1,243 @@
<script lang="ts">
import Markdown from '$lib/components/Markdown/Markdown.svelte';
import type { FileItem } from '$lib/types/api';
import { getAccessToken } from '$lib/api/client';
import { files as filesApi } from '$lib/api';
let { file = $bindable<FileItem | null>(null), downloadUrl }: {
file: FileItem | null;
downloadUrl: (id: string) => string;
} = $props();
type ViewerType = 'pdf' | 'image' | 'video' | 'audio' | 'markdown' | 'text' | 'none';
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const VIDEO_EXTS = new Set(['mp4', 'webm', 'ogv', 'mov']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac']);
const TEXT_EXTS = new Set([
'txt', 'json', 'csv', 'xml', 'yaml', 'yml', 'toml', 'ini', 'env',
'sh', 'bash', 'zsh', 'fish',
'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs',
'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php',
'html', 'css', 'scss', 'less', 'svelte', 'vue',
'sql', 'graphql', 'proto', 'dockerfile', 'makefile',
'log', 'gitignore', 'gitattributes', 'editorconfig',
]);
function ext(filename: string): string {
const parts = filename.toLowerCase().split('.');
return parts.length > 1 ? parts[parts.length - 1] : '';
}
function viewerType(f: FileItem): ViewerType {
const e = ext(f.name);
if (e === 'pdf') return 'pdf';
if (IMAGE_EXTS.has(e)) return 'image';
if (VIDEO_EXTS.has(e)) return 'video';
if (AUDIO_EXTS.has(e)) return 'audio';
if (e === 'md' || e === 'mdx') return 'markdown';
if (TEXT_EXTS.has(e)) return 'text';
return 'none';
}
function authFetch(url: string): Promise<Response> {
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
return fetch(url, { headers });
}
let textContent = $state('');
let textLoading = $state(false);
let textError = $state('');
let blobUrl = $state('');
let blobLoading = $state(false);
let blobError = $state('');
let activeType = $derived(file ? viewerType(file) : 'none');
let rawUrl = $derived(file ? downloadUrl(file.id) : '');
$effect(() => {
const needsBlob = activeType === 'pdf' || activeType === 'image' || activeType === 'video' || activeType === 'audio';
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = '';
}
blobError = '';
if (!file || !needsBlob) return;
blobLoading = true;
authFetch(rawUrl)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then((b) => { blobUrl = URL.createObjectURL(b); })
.catch((e) => { blobError = e.message; })
.finally(() => { blobLoading = false; });
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
});
$effect(() => {
textContent = '';
textError = '';
if (!file || (activeType !== 'text' && activeType !== 'markdown')) return;
textLoading = true;
authFetch(rawUrl)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then((t) => { textContent = t; })
.catch((e) => { textError = e.message; })
.finally(() => { textLoading = false; });
});
function close() {
file = null;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
function formatSize(bytes: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if file}
<div class="fixed inset-0 z-50 flex flex-col bg-neutral-950/95 backdrop-blur-sm">
<div class="flex items-center justify-between px-4 py-3 border-b border-neutral-700 shrink-0 bg-neutral-900">
<div class="flex items-center gap-3 min-w-0">
<div class="text-sm font-medium text-white truncate">{file.name}</div>
{#if file.size_bytes}
<div class="text-xs text-neutral-500 shrink-0">{formatSize(file.size_bytes)}</div>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0 ml-4">
<button
onclick={() => filesApi.download(file.id, file.name)}
class="flex items-center gap-1.5 text-xs text-neutral-300 hover:text-white bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Download
</button>
<button
onclick={close}
class="text-neutral-400 hover:text-white p-1.5 rounded hover:bg-neutral-700 transition-colors"
title="Close (Esc)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
</div>
<div class="flex-1 overflow-hidden flex items-center justify-center">
{#if activeType === 'pdf'}
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
{:else if blobError}
<div class="text-red-400">Failed to load: {blobError}</div>
{:else}
<iframe
src={blobUrl}
title={file.name}
class="w-full h-full border-0"
></iframe>
{/if}
{:else if activeType === 'image'}
<div class="w-full h-full overflow-auto flex items-center justify-center p-4">
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
{:else if blobError}
<div class="text-red-400">Failed to load: {blobError}</div>
{:else}
<img
src={blobUrl}
alt={file.name}
class="max-w-full max-h-full object-contain rounded shadow-lg"
/>
{/if}
</div>
{:else if activeType === 'video'}
<div class="w-full h-full flex items-center justify-center p-4">
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
{:else if blobError}
<div class="text-red-400">Failed to load: {blobError}</div>
{:else}
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={blobUrl}
controls
class="max-w-full max-h-full rounded shadow-lg"
></video>
{/if}
</div>
{:else if activeType === 'audio'}
<div class="flex flex-col items-center justify-center gap-6 p-8">
{#if blobLoading}
<div class="text-neutral-400">Loading…</div>
{:else if blobError}
<div class="text-red-400">Failed to load: {blobError}</div>
{:else}
<div class="w-24 h-24 rounded-full bg-neutral-800 border border-neutral-600 flex items-center justify-center">
<svg class="w-10 h-10 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path></svg>
</div>
<div class="text-neutral-300 text-sm font-medium">{file.name}</div>
<audio src={blobUrl} controls class="w-80 max-w-full"></audio>
{/if}
</div>
{:else if activeType === 'markdown'}
<div class="w-full h-full overflow-auto">
{#if textLoading}
<div class="flex items-center justify-center h-full text-neutral-400">Loading…</div>
{:else if textError}
<div class="flex items-center justify-center h-full text-red-400">Failed to load: {textError}</div>
{:else}
<div class="max-w-3xl mx-auto px-8 py-8">
<Markdown content={textContent} />
</div>
{/if}
</div>
{:else if activeType === 'text'}
<div class="w-full h-full overflow-auto">
{#if textLoading}
<div class="flex items-center justify-center h-full text-neutral-400">Loading…</div>
{:else if textError}
<div class="flex items-center justify-center h-full text-red-400">Failed to load: {textError}</div>
{:else}
<pre class="p-6 text-sm text-neutral-200 font-mono leading-relaxed whitespace-pre-wrap break-words">{textContent}</pre>
{/if}
</div>
{:else}
<div class="flex flex-col items-center justify-center gap-4 text-neutral-400">
<svg class="w-16 h-16 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>
<p class="text-sm">No preview available for this file type.</p>
<button
onclick={() => file && filesApi.download(file.id, file.name)}
class="flex items-center gap-2 text-sm text-white bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Download {file.name}
</button>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { browser } from '$app/environment';
import type { FileItem } from '$lib/types/api';
import { resolveFileRefs } from '$lib/utils/fileRefs';
import { files as filesApi } from '$lib/api';
let { content = '', files = [] as FileItem[] } = $props();
let htmlContent = $derived.by(() => {
if (!content) return '';
const resolved = files.length > 0 ? resolveFileRefs(content, files) : content;
const parsed = marked.parse(resolved);
if (browser) {
return DOMPurify.sanitize(parsed as string);
}
return parsed as string;
});
function handleClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const anchor = target.closest('a') as HTMLAnchorElement | null;
if (!anchor) return;
const href = anchor.getAttribute('href') ?? '';
if (!href.startsWith('#file-dl:')) return;
e.preventDefault();
const rest = href.slice('#file-dl:'.length);
const colon = rest.indexOf(':');
if (colon === -1) return;
const id = rest.slice(0, colon);
const name = decodeURIComponent(rest.slice(colon + 1));
filesApi.download(id, name).catch(() => {});
}
</script>
<div
class="prose prose-invert max-w-none prose-sm sm:prose-base prose-neutral"
onclick={handleClick}
role="presentation"
>
{@html htmlContent}
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
let { isOpen = $bindable(false), title, children, onClose = () => {} } = $props();
function close() {
isOpen = false;
if (onClose) onClose();
}
</script>
{#if isOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm overflow-y-auto">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0" onclick={close}></div>
<div class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full max-w-2xl max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-neutral-700 shrink-0">
<h2 class="text-xl font-semibold text-white">{title}</h2>
<button onclick={close} class="text-neutral-400 hover:text-white transition-colors p-1 rounded-md hover:bg-neutral-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
{@render children()}
</div>
</div>
</div>
{/if}

1
src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,77 @@
import { setAccessToken } from '$lib/api/client';
import { auth as authApi, users as usersApi } from '$lib/api';
import type { User } from '$lib/types/api';
function createAuthStore() {
let user = $state<User | null>(null);
let loading = $state(true);
async function init() {
const token =
typeof localStorage !== 'undefined' ? localStorage.getItem('access_token') : null;
if (!token) {
loading = false;
return;
}
try {
user = await usersApi.me();
} catch {
user = null;
} finally {
loading = false;
}
}
async function login(email: string, password: string) {
const res = await authApi.login(email, password);
setAccessToken(res.access_token);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('refresh_token', res.refresh_token);
localStorage.setItem('user_id', res.user.id);
}
user = res.user;
}
async function register(name: string, email: string, password: string) {
const res = await authApi.register(name, email, password);
setAccessToken(res.access_token);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('refresh_token', res.refresh_token);
localStorage.setItem('user_id', res.user.id);
}
user = res.user;
}
async function logout() {
try {
await authApi.logout();
} catch {
}
setAccessToken(null);
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_id');
}
user = null;
}
function setUser(u: User) {
user = u;
}
return {
get user() {
return user;
},
get loading() {
return loading;
},
init,
login,
register,
logout,
setUser
};
}
export const authStore = createAuthStore();

193
src/lib/types/api.ts Normal file
View File

@@ -0,0 +1,193 @@
export interface User {
id: string;
name: string;
email: string;
avatar_url: string;
created_at: string;
updated_at: string;
}
export interface Team {
id: string;
name: string;
workspace_id: string;
avatar_url?: string;
banner_url?: string;
member_count: number;
role_flags: number;
role_name: string;
created_at: string;
updated_at?: string;
}
export interface TeamMember {
id: string;
user_id: string;
team_id?: string;
name: string;
email: string;
role_flags: number;
role_name: string;
joined_at: string;
}
export interface Project {
id: string;
team_id: string;
team_name?: string;
name: string;
description: string;
visibility?: string;
is_public: boolean;
is_archived: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
export interface ProjectMember {
id: string;
user_id: string;
project_id: string;
name: string;
email: string;
role_flags: number;
role_name: string;
added_at: string;
}
export interface Subtask {
id: number;
text: string;
done: boolean;
}
export interface Card {
id: string;
column_id: string;
project_id: string;
title: string;
description: string;
priority: string;
color: string;
due_date: string;
assignees: string[];
subtasks: Subtask[];
position: number;
created_by: string;
created_at: string;
updated_at: string;
}
export interface Column {
id: string;
project_id: string;
title: string;
position: number;
cards?: Card[];
created_at: string;
updated_at: string;
}
export interface BoardData {
project_id: string;
columns: Column[];
}
export interface Event {
id: string;
title: string;
date: string;
time: string;
color: string;
description: string;
scope: string;
scope_id: string;
created_by: string;
created_at: string;
updated_at: string;
}
export interface Notification {
id: string;
user_id: string;
type: string;
message: string;
project_id: string;
read: boolean;
created_at: string;
}
export interface Doc {
id: string;
team_id: string;
title: string;
content: string;
created_by: string;
created_at: string;
updated_at: string;
}
export interface FileItem {
id: string;
project_id?: string;
team_id?: string;
user_id?: string;
parent_id?: string;
name: string;
type: string;
size_bytes: number;
storage_url: string;
created_by: string;
created_at: string;
updated_at: string;
}
export interface Webhook {
id: string;
project_id: string;
name: string;
type: string;
url: string;
status: string;
last_triggered?: string;
created_by: string;
created_at: string;
updated_at: string;
}
export interface Whiteboard {
id: string;
project_id: string;
data: string;
updated_at: string;
}
export interface AuthResponse {
access_token: string;
refresh_token: string;
user: User;
}
export interface ApiKey {
id: string;
name: string;
scopes: string[];
prefix: string;
last_used?: string;
created_at: string;
}
/** Returned only once when a key is first created — contains the raw key. */
export interface ApiKeyCreated extends ApiKey {
key: string;
}
export interface ChatMessage {
id: string;
team_id: string;
user_id: string;
user_name: string;
content: string;
created_at: string;
}

10
src/lib/types/roles.ts Normal file
View File

@@ -0,0 +1,10 @@
export enum RoleFlag {
Viewer = 1 << 0, // 1
Editor = 1 << 1, // 2
Admin = 1 << 2, // 4
Owner = 1 << 3, // 8
}
export function hasPermission(userRole: number, requiredRole: number): boolean {
return (userRole & requiredRole) === requiredRole;
}

11
src/lib/utils/fileRefs.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { FileItem } from '../types/api';
export function resolveFileRefs(content: string, files: FileItem[]): string {
return content.replace(/\$file:([^\s\])"']+)/g, (_match, name: string) => {
const file = files.find(f => f.name === name && f.type !== 'folder');
if (file) {
return `[${name}](#file-dl:${file.id}:${encodeURIComponent(name)})`;
}
return `\`unknown file: ${name}\``;
});
}

View File

@@ -0,0 +1,252 @@
<script lang="ts">
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { authStore } from "$lib/stores/auth.svelte";
import { notifications as notifApi } from "$lib/api";
let { children } = $props();
let currentPath = $derived($page.url.pathname);
let userInitial = $derived(
authStore.user?.name?.charAt(0).toUpperCase() ?? "U",
);
let unreadCount = $state(0);
onMount(async () => {
await authStore.init();
if (!authStore.user) {
goto("/login");
return;
}
try {
const all = await notifApi.list();
unreadCount = all.filter((n) => !n.read).length;
} catch {
unreadCount = 0;
}
});
async function logout() {
await authStore.logout();
goto("/login");
}
</script>
<div
class="h-screen w-screen bg-neutral-900 text-neutral-50 flex flex-col overflow-hidden"
>
<!-- Top Navbar -->
<header
class="h-16 shrink-0 bg-neutral-800 border-b border-neutral-700 flex items-center justify-between px-6 z-20 relative"
>
<div class="flex items-center space-x-8">
<a
href="/"
class="text-xl font-bold tracking-tight text-white hover:text-blue-400 transition-colors"
>FPMB</a
>
<nav class="hidden md:flex space-x-2">
<a
href="/"
class="px-3 py-2 text-sm font-medium rounded-md transition-colors {currentPath ===
'/'
? 'bg-blue-600 text-white'
: 'text-neutral-300 hover:bg-neutral-700 hover:text-white'}"
>
Dashboard
</a>
<a
href="/projects"
class="px-3 py-2 text-sm font-medium rounded-md transition-colors {currentPath.startsWith(
'/projects',
)
? 'bg-blue-600 text-white'
: 'text-neutral-300 hover:bg-neutral-700 hover:text-white'}"
>
Projects
</a>
<a
href="/calendar"
class="px-3 py-2 text-sm font-medium rounded-md transition-colors {currentPath.startsWith(
'/calendar',
)
? 'bg-blue-600 text-white'
: 'text-neutral-300 hover:bg-neutral-700 hover:text-white'}"
>
Calendar
</a>
<a
href="/files"
class="px-3 py-2 text-sm font-medium rounded-md transition-colors {currentPath.startsWith(
'/files',
)
? 'bg-blue-600 text-white'
: 'text-neutral-300 hover:bg-neutral-700 hover:text-white'}"
>
Files
</a>
<a
href="/docs"
class="px-3 py-2 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 {currentPath ===
'/docs'
? 'bg-blue-600 text-white'
: 'text-neutral-300 hover:bg-neutral-700 hover:text-white'}"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/></svg
>
Docs
</a>
<a
href="/api-docs"
class="px-3 py-2 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 {currentPath.startsWith(
'/api-docs',
)
? 'bg-blue-600 text-white'
: 'text-neutral-300 hover:bg-neutral-700 hover:text-white'}"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/></svg
>
API Docs
</a>
</nav>
</div>
<div class="flex items-center space-x-4">
<a
href="/notifications"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-full hover:bg-neutral-700 relative"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{#if unreadCount > 0}
<span
class="absolute top-1 right-1 min-w-[1.1rem] h-[1.1rem] bg-red-500 rounded-full border border-neutral-800 flex items-center justify-center text-[10px] font-bold text-white leading-none px-0.5"
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
{/if}
</a>
<!-- Mobile menu button -->
<button
class="md:hidden text-neutral-400 hover:text-white transition-colors p-2"
aria-label="Open menu"
>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<a
href="/settings/user"
class="hidden md:flex items-center space-x-3 p-1 rounded-full hover:bg-neutral-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-800 focus:ring-blue-500"
>
<div
class="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center text-sm font-medium text-white shadow-sm"
>
{userInitial}
</div>
</a>
<button
onclick={logout}
class="hidden md:flex items-center p-2 rounded-md text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
aria-label="Log out"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 flex flex-col min-w-0 overflow-hidden relative">
<div class="flex-1 overflow-auto">
<div class="flex flex-col min-h-full">
<div class="flex-1 p-6 lg:p-8">
{@render children()}
</div>
<!-- Footer -->
<footer class="shrink-0 px-6 lg:px-8 pb-6 pt-6">
<div class="border-t border-neutral-800 pt-5">
<div
class="flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-neutral-600"
>
<div class="flex items-center gap-1.5">
<span class="font-semibold text-neutral-500">FPMB</span>
<span>&middot;</span>
<span>Free Project Management Boards</span>
</div>
<div class="flex items-center gap-4">
<a
href="/api-docs"
class="hover:text-neutral-400 transition-colors">API Docs</a
>
<a
href="/settings/user"
class="hover:text-neutral-400 transition-colors">Settings</a
>
<span>v0.1.0</span>
</div>
</div>
</div>
</footer>
</div>
</div>
</main>
</div>

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { teams as teamsApi, projects as projectsApi } from "$lib/api";
import type { Team, Project } from "$lib/types/api";
let myTeams = $state<Team[]>([]);
let recentProjects = $state<Project[]>([]);
let loading = $state(true);
let showNewTeam = $state(false);
let newTeamName = $state("");
let savingTeam = $state(false);
onMount(async () => {
try {
[myTeams, recentProjects] = await Promise.all([
teamsApi.list(),
projectsApi.list(),
]);
} finally {
loading = false;
}
});
async function createTeam(e: SubmitEvent) {
e.preventDefault();
if (!newTeamName.trim()) return;
savingTeam = true;
try {
const team = await teamsApi.create(newTeamName.trim());
myTeams = [...myTeams, team];
newTeamName = "";
showNewTeam = false;
} catch {
} finally {
savingTeam = false;
}
}
</script>
<svelte:head>
<title>Dashboard — FPMB</title>
<meta
name="description"
content="Your FPMB dashboard — an overview of your teams and active projects."
/>
</svelte:head>
<div class="max-w-7xl mx-auto space-y-12 h-full flex flex-col">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">Dashboard</h1>
<p class="text-neutral-400 mt-1">
Welcome back. Here's an overview of your teams and active projects.
</p>
</div>
<!-- My Teams Section -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white flex items-center gap-2">
<Icon icon="lucide:users" class="w-5 h-5 text-neutral-400" />
My Teams
</h2>
<button
onclick={() => (showNewTeam = true)}
class="text-sm font-medium text-blue-500 hover:text-blue-400 transition-colors flex items-center gap-1"
>
<Icon icon="lucide:plus" class="w-4 h-4" />
Create Team
</button>
</div>
{#if loading}
<p class="text-neutral-500 text-sm">Loading...</p>
{:else if myTeams.length === 0}
<p class="text-neutral-500 text-sm">
You're not a member of any teams yet.
</p>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each myTeams as team (team.id)}
<a
href="/team/{team.id}"
class="block bg-neutral-800 rounded-lg border border-neutral-700 p-6 hover:border-blue-500 hover:shadow-md transition-all shadow-sm group"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-lg bg-blue-900/50 border border-blue-500/30 flex items-center justify-center text-blue-400 font-bold text-lg"
>
{team.name.charAt(0)}
</div>
<div>
<h3
class="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors"
>
{team.name}
</h3>
</div>
</div>
</div>
<div
class="mt-4 pt-4 border-t border-neutral-700 flex items-center justify-end"
>
<span
class="text-sm font-medium text-blue-500 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
Go to Team <Icon icon="lucide:arrow-right" class="w-4 h-4" />
</span>
</div>
</a>
{/each}
</div>
{/if}
</section>
<!-- Recent Projects Section -->
<section class="flex-1">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-white flex items-center gap-2">
<Icon icon="lucide:folder" class="w-5 h-5 text-neutral-400" />
Recent Projects
</h2>
<a
href="/projects"
class="text-sm font-medium text-blue-500 hover:text-blue-400 transition-colors"
>
View All
</a>
</div>
{#if loading}
<p class="text-neutral-500 text-sm">Loading...</p>
{:else if recentProjects.length === 0}
<p class="text-neutral-500 text-sm">No projects yet.</p>
{:else}
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden shadow-sm"
>
<ul class="divide-y divide-neutral-700">
{#each recentProjects.slice(0, 5) as project (project.id)}
<li class="hover:bg-neutral-750 transition-colors">
<a
href="/board/{project.id}"
class="px-6 py-4 flex items-center justify-between group"
>
<div class="flex items-center gap-4">
<div
class="w-8 h-8 rounded bg-neutral-700 flex items-center justify-center text-neutral-400 group-hover:text-blue-400 transition-colors"
>
<Icon icon="lucide:folder" class="w-4 h-4" />
</div>
<div>
<p
class="text-sm font-semibold text-white group-hover:text-blue-400 transition-colors"
>
{project.name}
</p>
<p class="text-xs text-neutral-500 mt-0.5">
Updated {new Date(project.updated_at).toLocaleDateString(
"en-US",
{ month: "2-digit", day: "2-digit", year: "numeric" },
)}
</p>
</div>
</div>
<Icon
icon="lucide:chevron-right"
class="w-5 h-5 text-neutral-500 group-hover:text-white transition-colors"
/>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</section>
</div>
{#if showNewTeam}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={() => (showNewTeam = false)}
>
<div
class="bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
>
<div
class="flex items-center justify-between p-4 border-b border-neutral-700"
>
<h2 class="text-lg font-semibold text-white">Create Team</h2>
<button
onclick={() => (showNewTeam = false)}
class="text-neutral-400 hover:text-white p-1 rounded hover:bg-neutral-700 transition-colors"
title="Close"
>
<Icon icon="lucide:x" class="w-5 h-5" />
</button>
</div>
<form onsubmit={createTeam} class="p-4 space-y-4">
<div>
<label
for="team-name"
class="block text-sm font-medium text-neutral-300 mb-1"
>Team Name</label
>
<input
id="team-name"
type="text"
bind:value={newTeamName}
placeholder="e.g. Engineering"
required
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => (showNewTeam = false)}
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={savingTeam}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50"
>
{savingTeam ? "Creating..." : "Create"}
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,633 @@
<script lang="ts">
let activeSection = $state("overview");
let copiedId = $state("");
const sections = [
{ id: "overview", label: "Overview" },
{ id: "auth", label: "Authentication" },
{ id: "api-keys", label: "API Keys" },
{ id: "users", label: "Users" },
{ id: "teams", label: "Teams" },
{ id: "projects", label: "Projects" },
{ id: "boards", label: "Boards & Cards" },
{ id: "events", label: "Events" },
{ id: "files", label: "Files" },
{ id: "webhooks", label: "Webhooks" },
{ id: "notifications", label: "Notifications" },
];
async function copy(text: string, id: string) {
await navigator.clipboard.writeText(text);
copiedId = id;
setTimeout(() => (copiedId = ""), 2000);
}
function scrollTo(id: string) {
activeSection = id;
document
.getElementById(id)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
</script>
<svelte:head>
<title>API Documentation — FPMB</title>
<meta
name="description"
content="Complete REST API reference for FPMB — authentication, API keys, projects, boards, teams, files, and more."
/>
</svelte:head>
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<div
class="w-9 h-9 rounded-lg bg-blue-600/20 border border-blue-500/30 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h1 class="text-3xl font-bold text-white tracking-tight">
API Documentation
</h1>
</div>
<p class="text-neutral-400 ml-12">
REST API reference for programmatic access to FPMB. All endpoints are
prefixed with <code
class="text-blue-300 bg-neutral-800 px-1.5 py-0.5 rounded text-sm"
>/api</code
>.
</p>
</div>
<div class="flex gap-8">
<!-- Sidebar nav -->
<aside class="hidden lg:block w-48 shrink-0">
<nav class="sticky top-6 space-y-0.5">
{#each sections as s}
<button
onclick={() => scrollTo(s.id)}
class="w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors {activeSection ===
s.id
? 'bg-blue-600/20 text-blue-300 font-medium'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'}"
>
{s.label}
</button>
{/each}
</nav>
</aside>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-12">
<!-- Overview -->
<section id="overview">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Overview
</h2>
<div class="space-y-4 text-sm text-neutral-300 leading-relaxed">
<p>
The FPMB REST API lets you integrate with projects, boards, teams,
files, and more. All responses are JSON.
</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
{#each [{ label: "Base URL", value: "http://localhost:8080/api" }, { label: "Content-Type", value: "application/json" }, { label: "Auth Scheme", value: "Bearer token / API key" }] as item}
<div
class="bg-neutral-800 border border-neutral-700 rounded-lg p-3"
>
<p class="text-xs text-neutral-500 mb-1">{item.label}</p>
<p class="font-mono text-xs text-blue-300">{item.value}</p>
</div>
{/each}
</div>
<div
class="bg-amber-900/20 border border-amber-700/40 rounded-lg p-4"
>
<p class="text-amber-300 text-sm">
<strong>Note:</strong> Protected endpoints require an
<code class="bg-neutral-800 px-1 rounded"
>Authorization: Bearer &lt;token&gt;</code
> header. Tokens can be JWT access tokens (from login) or personal
API keys.
</p>
</div>
</div>
</section>
<!-- Authentication -->
<section id="auth">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Authentication
</h2>
<div class="space-y-4">
{#each [{ method: "POST", path: "/auth/register", label: "Register", desc: "Create a new account. Returns access + refresh tokens.", body: `{ "name": "Alice", "email": "alice@example.com", "password": "secret" }`, response: `{ "access_token": "eyJ...", "refresh_token": "eyJ...", "user": { ... } }` }, { method: "POST", path: "/auth/login", label: "Login", desc: "Authenticate with email and password.", body: `{ "email": "alice@example.com", "password": "secret" }`, response: `{ "access_token": "eyJ...", "refresh_token": "eyJ..." }` }, { method: "POST", path: "/auth/refresh", label: "Refresh Token", desc: "Exchange a refresh token for a new access token.", body: `{ "refresh_token": "eyJ..." }`, response: `{ "access_token": "eyJ...", "refresh_token": "eyJ..." }` }, { method: "POST", path: "/auth/logout", label: "Logout", desc: "Invalidate the current session (requires auth).", body: null, response: `{ "message": "Logged out successfully" }` }] as ep}
<div
class="bg-neutral-800 border border-neutral-700 rounded-lg overflow-hidden"
>
<div
class="flex items-center gap-3 px-4 py-3 border-b border-neutral-700"
>
<span
class="text-xs font-bold px-2 py-0.5 rounded bg-green-700/60 text-green-300"
>{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200">{ep.path}</code
>
<span class="text-sm text-neutral-500 ml-auto">{ep.label}</span>
</div>
<div class="px-4 py-3 text-sm text-neutral-400">{ep.desc}</div>
{#if ep.body}
<div class="border-t border-neutral-700/50">
<p class="text-xs text-neutral-500 px-4 pt-2">Request body</p>
<pre
class="text-xs font-mono text-neutral-300 px-4 pb-3 overflow-x-auto">{ep.body}</pre>
</div>
{/if}
<div class="border-t border-neutral-700/50">
<p class="text-xs text-neutral-500 px-4 pt-2">Response</p>
<pre
class="text-xs font-mono text-neutral-300 px-4 pb-3 overflow-x-auto">{ep.response}</pre>
</div>
</div>
{/each}
</div>
</section>
<!-- API Keys -->
<section id="api-keys">
<h2
class="text-xl font-bold text-white mb-1 pb-2 border-b border-neutral-700"
>
API Keys
</h2>
<p class="text-sm text-neutral-400 mb-4">
Personal API keys can be used instead of JWT tokens. Pass them the
same way: <code class="bg-neutral-800 px-1 rounded text-blue-300"
>Authorization: Bearer fpmb_...</code
>
</p>
<!-- Scopes table -->
<div
class="mb-6 bg-neutral-800 border border-neutral-700 rounded-lg overflow-hidden"
>
<div class="px-4 py-3 border-b border-neutral-700">
<h3 class="text-sm font-semibold text-white">Available Scopes</h3>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-700 text-left">
<th
class="px-4 py-2 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Scope</th
>
<th
class="px-4 py-2 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Description</th
>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700/60">
{#each [["read:projects", "List and view projects"], ["write:projects", "Create, update, and delete projects"], ["read:boards", "Read board columns and cards"], ["write:boards", "Create, move, and delete cards and columns"], ["read:teams", "List teams and their members"], ["write:teams", "Create and manage teams"], ["read:files", "Browse and download files"], ["write:files", "Upload, create folders, and delete files"], ["read:notifications", "Read notifications"]] as [scope, desc]}
<tr>
<td class="px-4 py-2"
><code
class="text-xs font-mono text-blue-300 bg-blue-900/20 px-1.5 py-0.5 rounded"
>{scope}</code
></td
>
<td class="px-4 py-2 text-neutral-400 text-xs">{desc}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="space-y-4">
{#each [{ method: "GET", path: "/users/me/api-keys", label: "List keys", auth: true, desc: "Returns all active (non-revoked) API keys for the authenticated user.", body: null, response: `[{ "id": "...", "name": "CI Pipeline", "scopes": ["read:projects"], "prefix": "fpmb_ab12", "created_at": "..." }]` }, { method: "POST", path: "/users/me/api-keys", label: "Create key", auth: true, desc: "Generate a new API key. The raw key is returned only once.", body: `{ "name": "My Key", "scopes": ["read:projects", "read:boards"] }`, response: `{ "id": "...", "name": "My Key", "key": "fpmb_...", "scopes": [...], "prefix": "fpmb_ab12", "created_at": "..." }` }, { method: "DELETE", path: "/users/me/api-keys/:keyId", label: "Revoke key", auth: true, desc: "Permanently revokes an API key. This cannot be undone.", body: null, response: `{ "message": "Key revoked" }` }] as ep}
<div
class="bg-neutral-800 border border-neutral-700 rounded-lg overflow-hidden"
>
<div
class="flex items-center gap-3 px-4 py-3 border-b border-neutral-700"
>
<span
class="text-xs font-bold px-2 py-0.5 rounded {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'POST'
? 'bg-green-700/60 text-green-300'
: 'bg-red-700/60 text-red-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200">{ep.path}</code
>
{#if ep.auth}<span
class="text-xs bg-yellow-700/40 text-yellow-300 border border-yellow-700/40 px-1.5 py-0.5 rounded ml-1"
>🔒 auth</span
>{/if}
<span class="text-sm text-neutral-500 ml-auto">{ep.label}</span>
</div>
<div class="px-4 py-3 text-sm text-neutral-400">{ep.desc}</div>
{#if ep.body}
<div class="border-t border-neutral-700/50">
<p class="text-xs text-neutral-500 px-4 pt-2">Request body</p>
<pre
class="text-xs font-mono text-neutral-300 px-4 pb-3 overflow-x-auto">{ep.body}</pre>
</div>
{/if}
<div class="border-t border-neutral-700/50">
<p class="text-xs text-neutral-500 px-4 pt-2">Response</p>
<pre
class="text-xs font-mono text-neutral-300 px-4 pb-3 overflow-x-auto">{ep.response}</pre>
</div>
</div>
{/each}
</div>
</section>
<!-- Users -->
<section id="users">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Users
</h2>
<div class="space-y-4">
{#each [{ method: "GET", path: "/users/me", label: "Get current user", desc: "Returns the authenticated user's profile." }, { method: "PUT", path: "/users/me", label: "Update profile", desc: "Update name, email, or avatar_url.", body: '{ "name": "Alice", "email": "alice@example.com" }' }, { method: "PUT", path: "/users/me/password", label: "Change password", desc: "Change account password.", body: '{ "current_password": "old", "new_password": "new" }' }, { method: "POST", path: "/users/me/avatar", label: "Upload avatar", desc: "Multipart/form-data. Field: file." }, { method: "GET", path: "/users/me/avatar", label: "Get avatar", desc: "Returns the avatar image file." }, { method: "GET", path: "/users/search?q=", label: "Search users", desc: "Search by name or email. Returns up to 10 results." }] as ep}
<div
class="bg-neutral-800 border border-neutral-700 rounded-lg overflow-hidden"
>
<div class="flex items-center gap-3 px-4 py-3">
<span
class="text-xs font-bold px-2 py-0.5 rounded {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'POST'
? 'bg-green-700/60 text-green-300'
: ep.method === 'DELETE'
? 'bg-red-700/60 text-red-300'
: 'bg-yellow-700/60 text-yellow-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200">{ep.path}</code
>
<span
class="text-xs bg-yellow-700/40 text-yellow-300 border border-yellow-700/40 px-1.5 py-0.5 rounded ml-1"
>🔒 auth</span
>
<span class="text-sm text-neutral-500 ml-auto">{ep.label}</span>
</div>
{#if ep.desc || ep.body}
<div
class="border-t border-neutral-700/50 px-4 py-3 text-sm text-neutral-400"
>
{ep.desc}
{#if ep.body}
<pre
class="mt-2 text-xs font-mono text-neutral-300 bg-neutral-900 rounded p-2 overflow-x-auto">{ep.body}</pre>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</section>
<!-- Teams -->
<section id="teams">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Teams
</h2>
<div class="space-y-2">
{#each [{ method: "GET", path: "/teams", label: "List teams" }, { method: "POST", path: "/teams", label: "Create team", body: '{ "name": "Engineering" }' }, { method: "GET", path: "/teams/:teamId", label: "Get team" }, { method: "PUT", path: "/teams/:teamId", label: "Update team", body: '{ "name": "New Name" }' }, { method: "DELETE", path: "/teams/:teamId", label: "Delete team" }, { method: "GET", path: "/teams/:teamId/members", label: "List members" }, { method: "POST", path: "/teams/:teamId/members/invite", label: "Invite member", body: '{ "email": "bob@example.com", "role_flags": 1 }' }, { method: "PUT", path: "/teams/:teamId/members/:userId", label: "Update member role", body: '{ "role_flags": 2 }' }, { method: "DELETE", path: "/teams/:teamId/members/:userId", label: "Remove member" }, { method: "GET", path: "/teams/:teamId/projects", label: "List team projects" }, { method: "POST", path: "/teams/:teamId/projects", label: "Create project", body: '{ "name": "Sprint 1", "description": "..." }' }, { method: "GET", path: "/teams/:teamId/events", label: "List team events" }, { method: "POST", path: "/teams/:teamId/events", label: "Create event" }, { method: "GET", path: "/teams/:teamId/docs", label: "List docs" }, { method: "POST", path: "/teams/:teamId/docs", label: "Create doc", body: '{ "title": "RFC", "content": "..." }' }, { method: "GET", path: "/teams/:teamId/files", label: "List files" }, { method: "POST", path: "/teams/:teamId/files/folder", label: "Create folder" }, { method: "POST", path: "/teams/:teamId/files/upload", label: "Upload file (multipart)" }, { method: "POST", path: "/teams/:teamId/avatar", label: "Upload avatar" }, { method: "POST", path: "/teams/:teamId/banner", label: "Upload banner" }] as ep}
<div class="bg-neutral-800 border border-neutral-700 rounded-md">
<div class="flex items-center gap-3 px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded w-14 text-center {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'POST'
? 'bg-green-700/60 text-green-300'
: ep.method === 'DELETE'
? 'bg-red-700/60 text-red-300'
: 'bg-yellow-700/60 text-yellow-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200 flex-1"
>{ep.path}</code
>
<span class="text-xs text-neutral-500">{ep.label}</span>
</div>
{#if ep.body}
<pre
class="text-xs font-mono text-neutral-400 px-4 pb-2.5 overflow-x-auto">{ep.body}</pre>
{/if}
</div>
{/each}
</div>
</section>
<!-- Projects -->
<section id="projects">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Projects
</h2>
<div class="space-y-2">
{#each [{ method: "GET", path: "/projects", label: "List all projects" }, { method: "POST", path: "/projects", label: "Create personal project", body: '{ "name": "My Project", "description": "..." }' }, { method: "GET", path: "/projects/:projectId", label: "Get project" }, { method: "PUT", path: "/projects/:projectId", label: "Update project", body: '{ "name": "New Name", "description": "..." }' }, { method: "PUT", path: "/projects/:projectId/archive", label: "Archive project" }, { method: "DELETE", path: "/projects/:projectId", label: "Delete project" }, { method: "GET", path: "/projects/:projectId/members", label: "List members" }, { method: "POST", path: "/projects/:projectId/members", label: "Add member", body: '{ "user_id": "...", "role_flags": 1 }' }, { method: "PUT", path: "/projects/:projectId/members/:userId", label: "Update member role" }, { method: "DELETE", path: "/projects/:projectId/members/:userId", label: "Remove member" }, { method: "GET", path: "/projects/:projectId/events", label: "List events" }, { method: "POST", path: "/projects/:projectId/events", label: "Create event" }, { method: "GET", path: "/projects/:projectId/files", label: "List files" }, { method: "POST", path: "/projects/:projectId/files/folder", label: "Create folder" }, { method: "POST", path: "/projects/:projectId/files/upload", label: "Upload file (multipart)" }, { method: "GET", path: "/projects/:projectId/webhooks", label: "List webhooks" }, { method: "POST", path: "/projects/:projectId/webhooks", label: "Create webhook" }, { method: "GET", path: "/projects/:projectId/whiteboard", label: "Get whiteboard data" }, { method: "PUT", path: "/projects/:projectId/whiteboard", label: "Save whiteboard", body: '{ "data": "<canvas JSON>" }' }] as ep}
<div class="bg-neutral-800 border border-neutral-700 rounded-md">
<div class="flex items-center gap-3 px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded w-14 text-center {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'POST'
? 'bg-green-700/60 text-green-300'
: ep.method === 'DELETE'
? 'bg-red-700/60 text-red-300'
: 'bg-yellow-700/60 text-yellow-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200 flex-1"
>{ep.path}</code
>
<span class="text-xs text-neutral-500">{ep.label}</span>
</div>
{#if ep.body}
<pre
class="text-xs font-mono text-neutral-400 px-4 pb-2.5 overflow-x-auto">{ep.body}</pre>
{/if}
</div>
{/each}
</div>
</section>
<!-- Boards & Cards -->
<section id="boards">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Boards & Cards
</h2>
<div class="space-y-2">
{#each [{ method: "GET", path: "/projects/:projectId/board", label: "Get board (columns + cards)" }, { method: "POST", path: "/projects/:projectId/columns", label: "Create column", body: '{ "title": "To Do" }' }, { method: "PUT", path: "/projects/:projectId/columns/:columnId", label: "Rename column", body: '{ "title": "In Progress" }' }, { method: "PUT", path: "/projects/:projectId/columns/:columnId/position", label: "Reorder column", body: '{ "position": 2 }' }, { method: "DELETE", path: "/projects/:projectId/columns/:columnId", label: "Delete column" }, { method: "POST", path: "/projects/:projectId/columns/:columnId/cards", label: "Create card", body: '{ "title": "Task", "priority": "high", "assignees": ["email@x.com"] }' }, { method: "PUT", path: "/cards/:cardId", label: "Update card" }, { method: "PUT", path: "/cards/:cardId/move", label: "Move card", body: '{ "column_id": "...", "position": 0 }' }, { method: "DELETE", path: "/cards/:cardId", label: "Delete card" }] as ep}
<div class="bg-neutral-800 border border-neutral-700 rounded-md">
<div class="flex items-center gap-3 px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded w-14 text-center {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'POST'
? 'bg-green-700/60 text-green-300'
: ep.method === 'DELETE'
? 'bg-red-700/60 text-red-300'
: 'bg-yellow-700/60 text-yellow-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200 flex-1"
>{ep.path}</code
>
<span class="text-xs text-neutral-500">{ep.label}</span>
</div>
{#if ep.body}
<pre
class="text-xs font-mono text-neutral-400 px-4 pb-2.5 overflow-x-auto">{ep.body}</pre>
{/if}
</div>
{/each}
</div>
</section>
<!-- Events -->
<section id="events">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Events
</h2>
<div class="space-y-2">
{#each [{ method: "PUT", path: "/events/:eventId", label: "Update event", body: '{ "title": "Sprint Review", "date": "2025-03-01", "time": "14:00", "color": "#6366f1" }' }, { method: "DELETE", path: "/events/:eventId", label: "Delete event" }] as ep}
<div class="bg-neutral-800 border border-neutral-700 rounded-md">
<div class="flex items-center gap-3 px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded w-14 text-center {ep.method ===
'DELETE'
? 'bg-red-700/60 text-red-300'
: 'bg-yellow-700/60 text-yellow-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200 flex-1"
>{ep.path}</code
>
<span class="text-xs text-neutral-500">{ep.label}</span>
</div>
{#if ep.body}
<pre
class="text-xs font-mono text-neutral-400 px-4 pb-2.5 overflow-x-auto">{ep.body}</pre>
{/if}
</div>
{/each}
</div>
</section>
<!-- Files -->
<section id="files">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Files
</h2>
<div class="space-y-2">
{#each [{ method: "GET", path: "/users/me/files", label: "List personal files", note: "?parent_id= for folder navigation" }, { method: "POST", path: "/users/me/files/folder", label: "Create folder", body: '{ "name": "Designs", "parent_id": "" }' }, { method: "POST", path: "/users/me/files/upload", label: "Upload file", note: "Multipart: file field + optional parent_id" }, { method: "GET", path: "/files/:fileId/download", label: "Download file" }, { method: "DELETE", path: "/files/:fileId", label: "Delete file" }] as ep}
<div class="bg-neutral-800 border border-neutral-700 rounded-md">
<div class="flex items-center gap-3 px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded w-14 text-center {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'POST'
? 'bg-green-700/60 text-green-300'
: 'bg-red-700/60 text-red-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200 flex-1"
>{ep.path}</code
>
<span class="text-xs text-neutral-500">{ep.label}</span>
</div>
{#if ep.body || ep.note}
<p class="text-xs font-mono text-neutral-400 px-4 pb-2.5">
{ep.body ?? ep.note}
</p>
{/if}
</div>
{/each}
</div>
</section>
<!-- Webhooks -->
<section id="webhooks">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Webhooks
</h2>
<p class="text-sm text-neutral-400 mb-4">
Webhook types: <code class="bg-neutral-800 px-1 rounded text-blue-300"
>discord</code
>
·
<code class="bg-neutral-800 px-1 rounded text-blue-300">github</code>
· <code class="bg-neutral-800 px-1 rounded text-blue-300">gitea</code>
· <code class="bg-neutral-800 px-1 rounded text-blue-300">slack</code>
·
<code class="bg-neutral-800 px-1 rounded text-blue-300">custom</code>
</p>
<div class="space-y-2">
{#each [{ method: "GET", path: "/projects/:projectId/webhooks", label: "List webhooks" }, { method: "POST", path: "/projects/:projectId/webhooks", label: "Create webhook", body: '{ "name": "Deploy notify", "url": "https://...", "type": "discord" }' }, { method: "PUT", path: "/webhooks/:webhookId", label: "Update webhook" }, { method: "PUT", path: "/webhooks/:webhookId/toggle", label: "Enable / disable" }, { method: "DELETE", path: "/webhooks/:webhookId", label: "Delete webhook" }] as ep}
<div class="bg-neutral-800 border border-neutral-700 rounded-md">
<div class="flex items-center gap-3 px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded w-14 text-center {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'POST'
? 'bg-green-700/60 text-green-300'
: ep.method === 'DELETE'
? 'bg-red-700/60 text-red-300'
: 'bg-yellow-700/60 text-yellow-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200 flex-1"
>{ep.path}</code
>
<span class="text-xs text-neutral-500">{ep.label}</span>
</div>
{#if ep.body}
<pre
class="text-xs font-mono text-neutral-400 px-4 pb-2.5 overflow-x-auto">{ep.body}</pre>
{/if}
</div>
{/each}
</div>
</section>
<!-- Notifications -->
<section id="notifications">
<h2
class="text-xl font-bold text-white mb-4 pb-2 border-b border-neutral-700"
>
Notifications
</h2>
<div class="space-y-2">
{#each [{ method: "GET", path: "/notifications", label: "List notifications" }, { method: "PUT", path: "/notifications/read-all", label: "Mark all as read" }, { method: "PUT", path: "/notifications/:notifId/read", label: "Mark one as read" }, { method: "DELETE", path: "/notifications/:notifId", label: "Delete notification" }] as ep}
<div class="bg-neutral-800 border border-neutral-700 rounded-md">
<div class="flex items-center gap-3 px-4 py-2.5">
<span
class="text-xs font-bold px-2 py-0.5 rounded w-14 text-center {ep.method ===
'GET'
? 'bg-blue-700/60 text-blue-300'
: ep.method === 'DELETE'
? 'bg-red-700/60 text-red-300'
: 'bg-yellow-700/60 text-yellow-300'}">{ep.method}</span
>
<code class="text-sm font-mono text-neutral-200 flex-1"
>{ep.path}</code
>
<span class="text-xs text-neutral-500">{ep.label}</span>
</div>
</div>
{/each}
</div>
</section>
<!-- Quick example -->
<section
class="bg-neutral-800/60 border border-neutral-700 rounded-xl p-6 mb-4"
>
<h3
class="text-base font-semibold text-white mb-3 flex items-center gap-2"
>
<svg
class="w-4 h-4 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/></svg
>
Quick Example — curl
</h3>
<div class="relative">
<pre
class="text-xs font-mono text-neutral-300 leading-relaxed overflow-x-auto bg-neutral-900 rounded-lg p-4">curl -X GET https://your-fpmb-instance/api/projects \
-H "Authorization: Bearer fpmb_your_api_key_here" \
-H "Content-Type: application/json"</pre>
<button
onclick={() =>
copy(
`curl -X GET https://your-fpmb-instance/api/projects \\\n -H "Authorization: Bearer fpmb_your_api_key_here" \\\n -H "Content-Type: application/json"`,
"curl-example",
)}
class="absolute top-3 right-3 text-xs flex items-center gap-1 px-2.5 py-1.5 rounded border transition-colors {copiedId ===
'curl-example'
? 'bg-green-700 border-green-600 text-white'
: 'bg-neutral-700 border-neutral-600 text-neutral-400 hover:text-white hover:bg-neutral-600'}"
>
{#if copiedId === "curl-example"}
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
>
Copied
{:else}
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/></svg
>
Copy
{/if}
</button>
</div>
<p class="mt-3 text-sm text-neutral-500">
Generate an API key in <a
href="/settings/user"
class="text-blue-400 hover:underline">User Settings → API Keys</a
>.
</p>
</section>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import Calendar from "$lib/components/Calendar/Calendar.svelte";
import Modal from "$lib/components/Modal/Modal.svelte";
import { onMount } from "svelte";
import {
teams as teamsApi,
projects as projectsApi,
board as boardApi,
} from "$lib/api";
import type { Team, Event, Project } from "$lib/types/api";
let allEvents = $state<Event[]>([]);
let cardEvents = $state<
{
id: string;
date: string;
title: string;
time: string;
color: string;
description: string;
}[]
>([]);
let myTeams = $state<Team[]>([]);
let loading = $state(true);
let isModalOpen = $state(false);
let newEvent = $state({
title: "",
date: "",
time: "",
color: "blue",
description: "",
teamId: "",
});
let saving = $state(false);
const priorityColor: Record<string, string> = {
Low: "neutral",
Medium: "blue",
High: "yellow",
Urgent: "red",
};
onMount(async () => {
try {
const [teams, allProjects] = await Promise.all([
teamsApi.list(),
projectsApi.list(),
]);
myTeams = teams;
if (teams.length > 0) newEvent.teamId = teams[0].id;
const perTeam = await Promise.all(
teams.map((t) => teamsApi.listEvents(t.id)),
);
allEvents = perTeam.flat();
const boards = await Promise.all(
allProjects.map((p: Project) => boardApi.get(p.id).catch(() => null)),
);
cardEvents = boards.flatMap((b, i) => {
if (!b) return [];
return b.columns.flatMap((col) =>
(col.cards ?? [])
.filter((c) => c.due_date)
.map((c) => ({
id: c.id,
date: c.due_date.split("T")[0],
title: c.title,
time: "",
color: priorityColor[c.priority] ?? "blue",
description: `${allProjects[i].name}${c.priority}`,
})),
);
});
} finally {
loading = false;
}
});
let calendarEvents = $derived([
...allEvents.map((e) => ({
id: e.id,
date: e.date,
title: e.title,
time: e.time,
color: e.color,
description: e.description,
})),
...cardEvents,
]);
async function handleAddEvent(ev: SubmitEvent) {
ev.preventDefault();
if (!newEvent.title.trim() || !newEvent.date || !newEvent.teamId) return;
saving = true;
try {
const created = await teamsApi.createEvent(newEvent.teamId, {
title: newEvent.title,
date: newEvent.date,
time: newEvent.time,
color: newEvent.color,
description: newEvent.description,
});
allEvents = [...allEvents, created];
isModalOpen = false;
newEvent = {
title: "",
date: "",
time: "",
color: "blue",
description: "",
teamId: myTeams[0]?.id ?? "",
};
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Calendar — FPMB</title>
<meta
name="description"
content="View all team events, milestones, and card due dates across your projects in one calendar."
/>
</svelte:head>
<div class="max-w-7xl mx-auto flex flex-col">
<div class="flex items-center justify-between mb-6 shrink-0">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">
Organization Calendar
</h1>
<p class="text-neutral-400 mt-1">
Overview of all team events and milestones.
</p>
</div>
<div class="flex items-center space-x-4">
<button
onclick={() => (isModalOpen = true)}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors flex items-center space-x-2 text-sm"
>
<Icon icon="lucide:plus" class="w-4 h-4" />
<span>Add Event</span>
</button>
</div>
</div>
{#if loading}
<p class="text-neutral-500 text-sm">Loading events...</p>
{:else}
<Calendar events={calendarEvents} />
{/if}
</div>
<Modal bind:isOpen={isModalOpen} title="Add Event">
<form onsubmit={handleAddEvent} class="space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Title</label
>
<input
type="text"
bind:value={newEvent.title}
required
placeholder="Event title"
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Date</label
>
<input
type="date"
bind:value={newEvent.date}
required
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Time</label
>
<input
type="text"
bind:value={newEvent.time}
placeholder="e.g. 10:00 AM"
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Color</label
>
<select
bind:value={newEvent.color}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
>
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="red">Red</option>
<option value="yellow">Yellow</option>
<option value="purple">Purple</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Team</label
>
<select
bind:value={newEvent.teamId}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
>
{#each myTeams as team (team.id)}
<option value={team.id}>{team.name}</option>
{/each}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Description</label
>
<textarea
bind:value={newEvent.description}
placeholder="Optional description"
rows="2"
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white resize-none"
></textarea>
</div>
<div class="flex justify-end pt-2 border-t border-neutral-700 gap-3">
<button
type="button"
onclick={() => (isModalOpen = false)}
class="bg-transparent hover:bg-neutral-700 text-neutral-300 font-medium py-2 px-4 rounded-md border border-neutral-600 transition-colors text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={saving || !newEvent.title.trim() || !newEvent.date}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Saving..." : "Add Event"}
</button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,622 @@
<script lang="ts">
import Icon from "@iconify/svelte";
let activeSection = $state("overview");
const sections = [
{ id: "overview", label: "Overview", icon: "lucide:clipboard-list" },
{ id: "dashboard", label: "Dashboard", icon: "lucide:layout-dashboard" },
{ id: "teams", label: "Teams", icon: "lucide:users" },
{ id: "projects", label: "Projects", icon: "lucide:folder-kanban" },
{ id: "boards", label: "Board Views", icon: "lucide:bar-chart-3" },
{ id: "cards", label: "Cards & Tasks", icon: "lucide:square-check-big" },
{ id: "whiteboard", label: "Whiteboard", icon: "lucide:pen-tool" },
{ id: "chat", label: "Team Chat", icon: "lucide:message-circle" },
{ id: "calendar", label: "Calendar", icon: "lucide:calendar-days" },
{ id: "files", label: "Files", icon: "lucide:paperclip" },
{ id: "docs-feature", label: "Team Docs", icon: "lucide:file-text" },
{ id: "notifications", label: "Notifications", icon: "lucide:bell" },
{ id: "settings", label: "Settings", icon: "lucide:settings" },
{ id: "api-keys", label: "API Keys", icon: "lucide:key-round" },
{ id: "webhooks", label: "Webhooks", icon: "lucide:webhook" },
{ id: "shortcuts", label: "Keyboard Shortcuts", icon: "lucide:keyboard" },
];
</script>
<svelte:head>
<title>Documentation — FPMB</title>
<meta
name="description"
content="Complete guide to using FPMB — boards, teams, projects, whiteboard, chat, and more."
/>
</svelte:head>
<div class="max-w-6xl mx-auto flex gap-8">
<!-- Sidebar -->
<nav class="w-52 shrink-0 hidden lg:block sticky top-0 self-start pt-2">
<h2
class="text-xs font-semibold uppercase tracking-wider text-neutral-500 mb-3 px-2"
>
Documentation
</h2>
<ul class="space-y-0.5">
{#each sections as s}
<li>
<button
onclick={() => {
activeSection = s.id;
document
.getElementById(`section-${s.id}`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
class="w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors flex items-center gap-2 {activeSection ===
s.id
? 'bg-blue-600/15 text-blue-300 font-medium'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'}"
>
<Icon icon={s.icon} class="w-3.5 h-3.5 shrink-0" />
{s.label}
</button>
</li>
{/each}
</ul>
</nav>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-12">
<header>
<h1 class="text-3xl font-bold text-white mb-2">FPMB Documentation</h1>
<p class="text-neutral-400 text-base leading-relaxed">
Everything you need to know about Free Project Management Boards —
features, workflows, and tips.
</p>
</header>
<!-- Overview -->
<section id="section-overview" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:clipboard-list" class="w-5 h-5 text-blue-400" /> Overview
</h2>
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-5 space-y-3"
>
<p class="text-neutral-300 text-sm leading-relaxed">
FPMB is a full-featured project management platform built with <strong
class="text-white">SvelteKit</strong
>
and <strong class="text-white">Go</strong>. It provides Kanban boards,
Gantt charts, roadmaps, real-time collaboration, whiteboards, team
chat, file management, and more.
</p>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
{#each [{ label: "Board Views", value: "4 types", icon: "lucide:columns-3" }, { label: "Real-time", value: "WebSocket", icon: "lucide:radio" }, { label: "File Storage", value: "Unlimited", icon: "lucide:hard-drive" }, { label: "API Access", value: "Full REST", icon: "lucide:terminal" }] as stat}
<div class="bg-neutral-900/50 rounded-lg p-3 text-center">
<Icon
icon={stat.icon}
class="w-5 h-5 text-blue-400 mx-auto mb-1"
/>
<div class="text-lg font-bold text-blue-400">{stat.value}</div>
<div class="text-xs text-neutral-500">{stat.label}</div>
</div>
{/each}
</div>
</div>
</section>
<!-- Dashboard -->
<section id="section-dashboard" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:layout-dashboard" class="w-5 h-5 text-blue-400" /> Dashboard
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>The dashboard is your home page after logging in. It shows:</p>
<ul class="list-disc pl-5 space-y-1">
<li>
<strong class="text-white">Your teams</strong> — all teams you belong
to with quick access
</li>
<li>
<strong class="text-white">Recent projects</strong> — your most recently
updated projects
</li>
<li>
<strong class="text-white">Upcoming events</strong> — events from your
calendar across all teams
</li>
<li>
<strong class="text-white">Notifications</strong> — unread notification
count in the top bar
</li>
</ul>
</div>
</section>
<!-- Teams -->
<section id="section-teams" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:users" class="w-5 h-5 text-blue-400" /> Teams
</h2>
<div class="prose-sm text-neutral-300 space-y-3">
<p>
Teams are the organizational unit in FPMB. Every project belongs to a
team (or to you personally).
</p>
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-4 space-y-3"
>
<h3 class="text-sm font-semibold text-white">Creating a team</h3>
<p class="text-xs text-neutral-400">
Go to the Dashboard and click <strong class="text-neutral-200"
>"Create Team"</strong
>. You'll be the owner.
</p>
<h3 class="text-sm font-semibold text-white">Roles</h3>
<div class="grid grid-cols-2 gap-2 text-xs">
{#each [{ role: "Viewer", desc: "Read-only access to projects and boards", flag: "1", icon: "lucide:eye" }, { role: "Editor", desc: "Create and edit cards, columns, docs", flag: "2", icon: "lucide:pencil" }, { role: "Admin", desc: "Manage members, settings, webhooks", flag: "4", icon: "lucide:shield" }, { role: "Owner", desc: "Full control including team deletion", flag: "8", icon: "lucide:crown" }] as r}
<div class="bg-neutral-900/50 rounded p-2">
<div class="flex items-center gap-1.5">
<Icon icon={r.icon} class="w-3 h-3 text-blue-300" />
<span class="font-semibold text-blue-300">{r.role}</span>
<span class="text-neutral-500">(flag {r.flag})</span>
</div>
<p class="text-neutral-400 mt-0.5">{r.desc}</p>
</div>
{/each}
</div>
<h3 class="text-sm font-semibold text-white">Inviting members</h3>
<p class="text-xs text-neutral-400">
Team page → <strong class="text-neutral-200">Settings</strong> → Invite
by email. Choose a role before sending the invite.
</p>
</div>
</div>
</section>
<!-- Projects -->
<section id="section-projects" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:folder-kanban" class="w-5 h-5 text-blue-400" /> Projects
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
Projects contain boards, whiteboards, calendars, files, and webhooks.
</p>
<ul class="list-disc pl-5 space-y-1">
<li>
<strong class="text-white">Team projects</strong> — created from a team
page, visible to all team members
</li>
<li>
<strong class="text-white">Personal projects</strong> — created from
the Projects page, only you can access
</li>
<li>
<strong class="text-white">Visibility</strong> — Private (members only),
Unlisted (invite-only), or Public
</li>
<li>
<strong class="text-white">Archiving</strong> — archived projects become
read-only, preserving all data
</li>
</ul>
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-4 mt-3"
>
<h3
class="text-sm font-semibold text-white mb-2 flex items-center gap-1.5"
>
<Icon icon="lucide:settings" class="w-3.5 h-3.5" /> Project settings
</h3>
<p class="text-xs text-neutral-400">
Access from the board page (gear icon) or from <code
class="bg-neutral-700 px-1 rounded text-xs"
>/projects/[id]/settings</code
>. Here you can rename, update the description, archive, or delete
the project.
</p>
</div>
</div>
</section>
<!-- Board Views -->
<section id="section-boards" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:bar-chart-3" class="w-5 h-5 text-blue-400" /> Board Views
</h2>
<div class="prose-sm text-neutral-300 space-y-3">
<p>
Every project board supports <strong class="text-white"
>4 different views</strong
> of the same data. Switch between them using the view switcher tabs at
the top of the board.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each [{ name: "Kanban", icon: "lucide:columns-3", desc: "Classic column-based board. Drag and drop cards between columns. Add new columns and cards. This is the default view." }, { name: "Task Board", icon: "lucide:table", desc: "Spreadsheet-style table showing all tasks with Status, Priority, Due Date, Assignees, and Subtask progress in sortable rows." }, { name: "Gantt Chart", icon: "lucide:gantt-chart", desc: "Timeline view with horizontal bars from creation to due date. Day-level grid with today highlighted. Requires tasks to have due dates." }, { name: "Roadmap", icon: "lucide:milestone", desc: "Vertical milestone timeline. Each column is a stage. Shows progress bars for subtasks. Green milestones when all subtasks are complete." }] as view}
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-4"
>
<div class="flex items-center gap-2 mb-2">
<Icon icon={view.icon} class="w-4 h-4 text-blue-400" />
<h3 class="text-sm font-semibold text-white">{view.name}</h3>
</div>
<p class="text-xs text-neutral-400 leading-relaxed">
{view.desc}
</p>
</div>
{/each}
</div>
</div>
</section>
<!-- Cards & Tasks -->
<section id="section-cards" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:square-check-big" class="w-5 h-5 text-blue-400" /> Cards
& Tasks
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>Cards are the core unit of work. Each card has:</p>
<ul class="list-disc pl-5 space-y-1">
<li>
<strong class="text-white">Title</strong> — required, displayed prominently
on the board
</li>
<li>
<strong class="text-white">Description</strong> — supports
<strong class="text-blue-300">Markdown</strong> with preview toggle
</li>
<li>
<strong class="text-white">Priority</strong> — Low, Medium, High, or
Urgent (color-coded badges)
</li>
<li>
<strong class="text-white">Color label</strong> — visual sidebar indicator
(red, blue, green, purple, yellow)
</li>
<li>
<strong class="text-white">Due date</strong> — used in calendar, Gantt
chart, and roadmap views
</li>
<li>
<strong class="text-white">Assignees</strong> — mention with
<code class="bg-neutral-700 px-1 rounded text-xs">@email</code>,
shown as avatar circles
</li>
<li>
<strong class="text-white">Subtasks</strong> — checklist items with completion
tracking and progress bars
</li>
</ul>
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-4 mt-3"
>
<h3
class="text-sm font-semibold text-white mb-1 flex items-center gap-1.5"
>
<Icon icon="lucide:move" class="w-3.5 h-3.5" /> Moving cards
</h3>
<p class="text-xs text-neutral-400">
In Kanban view, drag and drop cards between columns. The card's
position and column are updated automatically via the API.
</p>
</div>
</div>
</section>
<!-- Whiteboard -->
<section id="section-whiteboard" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:pen-tool" class="w-5 h-5 text-blue-400" /> Whiteboard
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
Each project has a collaborative whiteboard accessible from the board
header (pen icon).
</p>
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-4 space-y-2"
>
<h3 class="text-sm font-semibold text-white mb-2">Tools</h3>
<div class="grid grid-cols-2 gap-1.5 text-xs">
{#each [{ tool: "Select", desc: "Click to select objects. Drag to move. Double-click to edit.", icon: "lucide:mouse-pointer" }, { tool: "Pen", desc: "Freehand drawing with configurable color and width.", icon: "lucide:pencil" }, { tool: "Rectangle", desc: "Click and drag to draw rectangles.", icon: "lucide:square" }, { tool: "Circle", desc: "Click and drag to draw circles.", icon: "lucide:circle" }, { tool: "Text", desc: "Click to place text. Set font size in toolbar.", icon: "lucide:type" }, { tool: "Eraser", desc: "Click on any object to delete it.", icon: "lucide:eraser" }] as t}
<div class="bg-neutral-900/50 rounded p-2 flex items-start gap-2">
<Icon
icon={t.icon}
class="w-3.5 h-3.5 text-blue-300 shrink-0 mt-0.5"
/>
<div>
<span class="font-semibold text-white">{t.tool}</span>
<span class="text-neutral-400 ml-1">{t.desc}</span>
</div>
</div>
{/each}
</div>
<h3 class="text-sm font-semibold text-white mt-3 mb-1">Features</h3>
<ul class="list-disc pl-5 space-y-0.5 text-xs text-neutral-400">
<li>
<strong class="text-neutral-200">Undo/Redo</strong> — Ctrl+Z / Ctrl+Shift+Z
(up to 100 steps)
</li>
<li>
<strong class="text-neutral-200">Export PNG</strong> — download the
whiteboard as a clean image
</li>
<li>
<strong class="text-neutral-200">Real-time collaboration</strong>
see other users' cursors live via WebSocket
</li>
<li>
<strong class="text-neutral-200">Auto-save</strong> — changes are saved
as JSON objects
</li>
</ul>
</div>
</div>
</section>
<!-- Team Chat -->
<section id="section-chat" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:message-circle" class="w-5 h-5 text-blue-400" /> Team
Chat
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
Each team has a real-time chat room accessible from the team page → <strong
class="text-white">Chat</strong
> button.
</p>
<ul class="list-disc pl-5 space-y-1">
<li>
<strong class="text-white">Real-time messaging</strong> — via WebSocket,
messages appear instantly
</li>
<li>
<strong class="text-white">Persistent history</strong> — all messages
saved to the database, infinite scroll to load older messages
</li>
<li>
<strong class="text-white">Typing indicators</strong> — see when teammates
are typing
</li>
<li>
<strong class="text-white">Online presence</strong> — colored avatars
show who's currently in the chat
</li>
<li>
<strong class="text-white">Message grouping</strong> — consecutive messages
from the same user within 5 minutes are grouped
</li>
<li>
<strong class="text-white">Multi-line support</strong> — Shift+Enter
for new lines, Enter to send
</li>
</ul>
</div>
</section>
<!-- Calendar -->
<section id="section-calendar" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:calendar-days" class="w-5 h-5 text-blue-400" /> Calendar
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>FPMB provides calendars at multiple levels:</p>
<ul class="list-disc pl-5 space-y-1">
<li>
<strong class="text-white">Global calendar</strong> — aggregates events
from all your teams
</li>
<li>
<strong class="text-white">Team calendar</strong> — events scoped to
a specific team
</li>
<li>
<strong class="text-white">Project calendar</strong> — events + card
due dates for a specific project
</li>
</ul>
<p>
Events have a title, description, date, time, and color label. Card
due dates automatically appear on their project calendar.
</p>
</div>
</section>
<!-- Files -->
<section id="section-files" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:paperclip" class="w-5 h-5 text-blue-400" /> Files
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
File storage with folder hierarchy is available at multiple levels:
</p>
<ul class="list-disc pl-5 space-y-1">
<li>
<strong class="text-white">Personal files</strong> — accessible from
the top nav "Files" link
</li>
<li>
<strong class="text-white">Team files</strong> — shared with all team
members
</li>
<li>
<strong class="text-white">Project files</strong> — attached to a specific
project
</li>
</ul>
<p>
You can create folders, upload files, download, and delete. Files are
stored in the <code class="bg-neutral-700 px-1 rounded text-xs"
>data/</code
> directory on the server.
</p>
</div>
</section>
<!-- Team Docs -->
<section id="section-docs-feature" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:file-text" class="w-5 h-5 text-blue-400" /> Team Docs
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
Teams have a built-in document editor for shared notes, meeting
minutes, and documentation.
</p>
<ul class="list-disc pl-5 space-y-1">
<li>Create and edit documents with a title and rich content</li>
<li>
Accessible from the team page → <strong class="text-white"
>Docs</strong
> button
</li>
<li>Documents are stored per-team and visible to all members</li>
</ul>
</div>
</section>
<!-- Notifications -->
<section id="section-notifications" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:bell" class="w-5 h-5 text-blue-400" /> Notifications
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
Notifications keep you updated on activity across your teams and
projects.
</p>
<ul class="list-disc pl-5 space-y-1">
<li>Bell icon in the navbar shows unread count</li>
<li>Mark individual notifications as read or mark all as read</li>
<li>
Triggered by team invites, task assignments, due date reminders, and
more
</li>
<li>Due date reminders run automatically every hour on the server</li>
</ul>
</div>
</section>
<!-- Settings -->
<section id="section-settings" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:settings" class="w-5 h-5 text-blue-400" /> Settings
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
User settings are available at <code
class="bg-neutral-700 px-1 rounded text-xs">/settings/user</code
> (click your avatar in the top bar).
</p>
<ul class="list-disc pl-5 space-y-1">
<li>
<strong class="text-white">Profile</strong> — update your name, email,
and avatar
</li>
<li>
<strong class="text-white">Password</strong> — change your password
</li>
<li>
<strong class="text-white">API keys</strong> — generate and manage API
keys (see below)
</li>
</ul>
</div>
</section>
<!-- API Keys -->
<section id="section-api-keys" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:key-round" class="w-5 h-5 text-blue-400" /> API Keys
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
Generate personal API keys for programmatic access to the FPMB REST
API.
</p>
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-4 space-y-2"
>
<h3 class="text-sm font-semibold text-white mb-1">Creating a key</h3>
<ol class="list-decimal pl-5 space-y-0.5 text-xs text-neutral-400">
<li>
Go to <strong class="text-neutral-200">Settings → API Keys</strong
>
</li>
<li>Enter a name and select scopes (read, write, admin)</li>
<li>Click <strong class="text-neutral-200">Generate</strong></li>
<li>Copy the key immediately — it's only shown once!</li>
</ol>
<h3 class="text-sm font-semibold text-white mt-3 mb-1">
Using a key
</h3>
<div
class="bg-neutral-900 rounded p-3 font-mono text-xs text-green-400"
>
curl -H "Authorization: Bearer YOUR_API_KEY" \<br
/>&nbsp;&nbsp;{typeof window !== "undefined"
? window.location.origin
: "https://your-domain.com"}/api/projects
</div>
<p class="text-xs text-neutral-500 mt-2">
See the <a href="/api-docs" class="text-blue-400 hover:underline"
>API Documentation</a
> for all available endpoints.
</p>
</div>
</div>
</section>
<!-- Webhooks -->
<section id="section-webhooks" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:webhook" class="w-5 h-5 text-blue-400" /> Webhooks
</h2>
<div class="prose-sm text-neutral-300 space-y-2">
<p>
Set up webhooks to receive HTTP notifications when events occur in
your projects.
</p>
<ul class="list-disc pl-5 space-y-1">
<li>
Configure from <strong class="text-white"
>Project Settings → Webhooks</strong
>
</li>
<li>
Choose event types: card created, card moved, card deleted, etc.
</li>
<li>Provide a URL — FPMB will POST JSON payloads to it</li>
<li>Toggle webhooks on/off without deleting them</li>
<li>View last triggered timestamp for debugging</li>
</ul>
</div>
</section>
<!-- Keyboard Shortcuts -->
<section id="section-shortcuts" class="scroll-mt-8">
<h2 class="text-xl font-bold text-white mb-3 flex items-center gap-2">
<Icon icon="lucide:keyboard" class="w-5 h-5 text-blue-400" /> Keyboard Shortcuts
</h2>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs">
{#each [{ keys: "Ctrl + Z", action: "Undo (whiteboard)" }, { keys: "Ctrl + Shift + Z", action: "Redo (whiteboard)" }, { keys: "Ctrl + Y", action: "Redo (whiteboard, alt)" }, { keys: "Delete / Backspace", action: "Delete selected object (whiteboard)" }, { keys: "Escape", action: "Deselect / close editing" }, { keys: "Enter", action: "Send message (chat) / confirm text (whiteboard)" }, { keys: "Shift + Enter", action: "New line in chat" }, { keys: "Double-click", action: "Edit object (whiteboard)" }] as shortcut}
<div
class="flex items-center justify-between bg-neutral-900/50 rounded p-2"
>
<span class="text-neutral-400">{shortcut.action}</span>
<kbd
class="bg-neutral-700 border border-neutral-600 rounded px-1.5 py-0.5 text-[10px] font-mono text-neutral-300"
>{shortcut.keys}</kbd
>
</div>
{/each}
</div>
</div>
</section>
<div class="h-8"></div>
</div>
</div>

View File

@@ -0,0 +1,382 @@
<script lang="ts">
import { users as usersApi, files as filesApi } from "$lib/api";
import type { FileItem } from "$lib/types/api";
import FileViewer from "$lib/components/FileViewer/FileViewer.svelte";
let folderStack = $state<{ id: string; name: string }[]>([]);
let currentParentId = $derived(
folderStack.length > 0 ? folderStack[folderStack.length - 1].id : "",
);
let fileList = $state<FileItem[]>([]);
let loading = $state(true);
let folderName = $state("");
let showFolderInput = $state(false);
let savingFolder = $state(false);
async function loadFiles(parentId: string) {
loading = true;
try {
fileList = await usersApi.listFiles(parentId);
} catch {
fileList = [];
} finally {
loading = false;
}
}
$effect(() => {
loadFiles(currentParentId);
});
function formatSize(bytes: number): string {
if (!bytes) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
}
async function createFolder(e: SubmitEvent) {
e.preventDefault();
if (!folderName.trim()) return;
savingFolder = true;
try {
const created = await usersApi.createFolder(
folderName.trim(),
currentParentId,
);
fileList = [created, ...fileList];
folderName = "";
showFolderInput = false;
} catch {
} finally {
savingFolder = false;
}
}
async function handleUpload(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const created = await usersApi.uploadFile(file, currentParentId);
fileList = [...fileList, created];
} catch {}
input.value = "";
}
async function deleteFile(id: string) {
if (!confirm("Delete this item?")) return;
try {
await filesApi.delete(id);
fileList = fileList.filter((f) => f.id !== id);
} catch {}
}
function openFolder(folder: FileItem) {
folderStack = [...folderStack, { id: folder.id, name: folder.name }];
}
function navigateToBreadcrumb(index: number) {
if (index === -1) {
folderStack = [];
} else {
folderStack = folderStack.slice(0, index + 1);
}
}
function getIcon(type: string) {
if (type === "folder") {
return `<svg class="w-6 h-6 text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path></svg>`;
}
return `<svg class="w-6 h-6 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>`;
}
let viewingFile = $state<FileItem | null>(null);
let fileInput: HTMLInputElement;
</script>
<svelte:head>
<title>My Files — FPMB</title>
<meta
name="description"
content="Browse, upload, and organise your personal files and folders in FPMB."
/>
</svelte:head>
<div class="h-full flex flex-col -m-6 p-6 overflow-hidden">
<header
class="flex flex-col md:flex-row md:items-center justify-between mb-6 pb-6 border-b border-neutral-700 shrink-0 gap-4"
>
<div class="flex items-center space-x-4">
<a
href="/"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
></path></svg
>
</a>
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
My Files
</h1>
<div
class="text-sm text-neutral-400 flex items-center space-x-2 mt-1 flex-wrap gap-y-1"
>
<button
onclick={() => navigateToBreadcrumb(-1)}
class="hover:text-blue-400 transition-colors">Root</button
>
{#each folderStack as crumb, i}
<span>/</span>
<button
onclick={() => navigateToBreadcrumb(i)}
class="hover:text-blue-400 transition-colors">{crumb.name}</button
>
{/each}
<span>/</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<button
onclick={() => (showFolderInput = !showFolderInput)}
class="bg-neutral-800 hover:bg-neutral-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors text-sm flex items-center"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
></path></svg
>
New Folder
</button>
<input
bind:this={fileInput}
type="file"
class="hidden"
onchange={handleUpload}
/>
<button
onclick={() => fileInput.click()}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
></path></svg
>
Upload
</button>
</div>
</header>
{#if showFolderInput}
<form onsubmit={createFolder} class="mb-4 flex gap-2">
<input
type="text"
bind:value={folderName}
placeholder="Folder name"
required
class="flex-1 px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<button
type="submit"
disabled={savingFolder}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50"
>
{savingFolder ? "Creating..." : "Create"}
</button>
<button
type="button"
onclick={() => (showFolderInput = false)}
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
>
Cancel
</button>
</form>
{/if}
<div
class="flex-1 overflow-auto bg-neutral-800 rounded-lg shadow-sm border border-neutral-700"
>
{#if loading}
<div class="p-12 text-center text-neutral-400">Loading files...</div>
{:else}
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-neutral-850 border-b border-neutral-700">
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Name</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-32 hidden sm:table-cell"
>Size</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-40 hidden md:table-cell"
>Last Modified</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider text-right w-24"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{#each fileList as file (file.id)}
<tr
class="hover:bg-neutral-750 transition-colors group cursor-pointer"
ondblclick={() =>
file.type === "folder"
? openFolder(file)
: (viewingFile = file)}
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="shrink-0 flex items-center justify-center">
{@html getIcon(file.type)}
</div>
<div class="ml-4">
{#if file.type === "folder"}
<button
onclick={() => openFolder(file)}
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors text-left"
>{file.name}</button
>
{:else}
<div
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors"
>
{file.name}
</div>
{/if}
<div class="text-xs text-neutral-500 sm:hidden mt-1">
{formatSize(file.size_bytes)}{formatDate(
file.updated_at,
)}
</div>
</div>
</div>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden sm:table-cell"
>
{formatSize(file.size_bytes)}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden md:table-cell"
>
{formatDate(file.updated_at)}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
>
<div
class="flex items-center justify-end space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if file.type === "file" && file.storage_url}
<button
onclick={() => filesApi.download(file.id, file.name)}
class="text-neutral-400 hover:text-white p-1 rounded"
title="Download"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
></path></svg
>
</button>
{/if}
<button
onclick={() => deleteFile(file.id)}
class="text-neutral-400 hover:text-red-400 p-1 rounded"
title="Delete"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path></svg
>
</button>
</div>
</td>
</tr>
{/each}
{#if fileList.length === 0}
<tr>
<td colspan="4" class="px-6 py-12 text-center text-neutral-400">
<div class="flex flex-col items-center">
<svg
class="w-12 h-12 text-neutral-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
></path></svg
>
<p>This folder is empty.</p>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
{/if}
</div>
</div>
<FileViewer bind:file={viewingFile} downloadUrl={filesApi.downloadUrl} />

View File

@@ -0,0 +1,175 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { notifications as notifApi } from "$lib/api";
import type { Notification } from "$lib/types/api";
let notifications = $state<Notification[]>([]);
let loading = $state(true);
onMount(async () => {
try {
notifications = await notifApi.list();
} finally {
loading = false;
}
});
async function markAllRead() {
await notifApi.markAllRead();
notifications = notifications.map((n) => ({ ...n, read: true }));
}
async function markRead(id: string) {
await notifApi.markRead(id);
notifications = notifications.map((n) =>
n.id === id ? { ...n, read: true } : n,
);
}
async function deleteNotification(id: string) {
await notifApi.delete(id);
notifications = notifications.filter((n) => n.id !== id);
}
function labelForType(type: string) {
if (type === "assign") return "Task Assigned";
if (type === "team_invite") return "Team Invite";
if (type === "due_soon") return "Due Soon";
if (type === "mention") return "Mention";
return "Notification";
}
function iconForType(type: string) {
if (type === "assign") return "lucide:user-plus";
if (type === "team_invite") return "lucide:users";
if (type === "due_soon") return "lucide:clock";
if (type === "mention") return "lucide:at-sign";
return "lucide:bell";
}
function colorForType(type: string) {
if (type === "assign") return "text-green-400";
if (type === "team_invite") return "text-purple-400";
if (type === "due_soon") return "text-orange-400";
if (type === "mention") return "text-blue-400";
return "text-yellow-400";
}
function relativeTime(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
}
</script>
<svelte:head>
<title>Notifications — FPMB</title>
<meta
name="description"
content="View and manage your FPMB notifications for project updates, task assignments, and team activity."
/>
</svelte:head>
<div class="max-w-4xl mx-auto space-y-6">
<div class="flex items-end justify-between border-b border-neutral-700 pb-4">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">
Notifications
</h1>
<p class="text-neutral-400 mt-1">
Stay updated on your projects and tasks.
</p>
</div>
<button
onclick={markAllRead}
class="text-sm font-medium text-blue-500 hover:text-blue-400 transition-colors"
>
Mark all as read
</button>
</div>
{#if loading}
<p class="text-neutral-500 text-sm">Loading...</p>
{:else if notifications.length === 0}
<div
class="text-center py-12 bg-neutral-800 rounded-lg border border-neutral-700"
>
<Icon
icon="lucide:bell-off"
class="w-12 h-12 text-neutral-600 mx-auto mb-3"
/>
<h3 class="text-lg font-medium text-white mb-1">All caught up!</h3>
<p class="text-neutral-400 text-sm">You have no new notifications.</p>
</div>
{:else}
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 shadow-sm overflow-hidden"
>
<ul class="divide-y divide-neutral-700">
{#each notifications as notification (notification.id)}
<li
class="p-4 hover:bg-neutral-750 transition-colors {notification.read
? 'opacity-60'
: ''}"
>
<div class="flex items-start gap-4">
<div class="shrink-0 mt-1">
<div
class="w-10 h-10 rounded-full bg-neutral-700 border border-neutral-600 flex items-center justify-center"
>
<Icon
icon={iconForType(notification.type)}
class="w-5 h-5 {colorForType(notification.type)}"
/>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-semibold text-white truncate pr-4">
{labelForType(notification.type)}
</p>
<span class="text-xs text-neutral-500 whitespace-nowrap"
>{relativeTime(notification.created_at)}</span
>
</div>
<p class="text-sm text-neutral-300">
{notification.message}
</p>
</div>
<div class="shrink-0 mt-1 flex flex-col items-center gap-2">
{#if !notification.read}
<div class="w-2.5 h-2.5 bg-blue-500 rounded-full"></div>
<button
onclick={() => markRead(notification.id)}
class="text-xs text-neutral-400 hover:text-white"
title="Mark as read"
>
<Icon icon="lucide:check" class="w-4 h-4" />
</button>
{/if}
<button
onclick={() => deleteNotification(notification.id)}
class="text-xs text-neutral-500 hover:text-red-400 transition-colors"
title="Delete"
>
<Icon icon="lucide:trash-2" class="w-4 h-4" />
</button>
</div>
</div>
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@@ -0,0 +1,306 @@
<script lang="ts">
import { onMount } from "svelte";
import Icon from "@iconify/svelte";
import { projects as projectsApi, teams as teamsApi } from "$lib/api";
import type { Project, Team } from "$lib/types/api";
let projects = $state<Project[]>([]);
let teams = $state<Team[]>([]);
let loading = $state(true);
let error = $state("");
let showModal = $state(false);
let newName = $state("");
let newDesc = $state("");
let selectedTeamId = $state("");
let creating = $state(false);
let createError = $state("");
onMount(async () => {
try {
const [p, t] = await Promise.all([projectsApi.list(), teamsApi.list()]);
projects = p;
teams = t;
} catch (e: unknown) {
error = e instanceof Error ? e.message : "Failed to load projects";
} finally {
loading = false;
}
});
function openModal() {
newName = "";
newDesc = "";
selectedTeamId = "";
createError = "";
showModal = true;
}
function closeModal() {
showModal = false;
}
async function submitCreate() {
if (!newName.trim()) {
createError = "Project name is required.";
return;
}
creating = true;
createError = "";
try {
let project: Project;
if (selectedTeamId) {
project = await teamsApi.createProject(
selectedTeamId,
newName.trim(),
newDesc.trim(),
);
} else {
project = await projectsApi.createPersonal(
newName.trim(),
newDesc.trim(),
);
}
projects = [project, ...projects];
showModal = false;
} catch (e: unknown) {
createError =
e instanceof Error ? e.message : "Failed to create project.";
} finally {
creating = false;
}
}
function statusLabel(p: Project): string {
return p.is_archived ? "Archived" : "Active";
}
function statusClass(p: Project): string {
return p.is_archived
? "bg-neutral-700 text-neutral-400"
: "bg-blue-900/50 text-blue-300";
}
</script>
<svelte:head>
<title>Projects — FPMB</title>
<meta
name="description"
content="View and manage all your personal and team projects in FPMB."
/>
</svelte:head>
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-white">Projects</h1>
<button
onclick={openModal}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-md transition-colors"
>
<Icon icon="lucide:plus" class="w-4 h-4" />
New Project
</button>
</div>
{#if loading}
<p class="text-neutral-500 text-sm">Loading...</p>
{:else if error}
<p class="text-red-400 text-sm">{error}</p>
{:else if projects.length === 0}
<div class="flex flex-col items-center justify-center py-24 text-center">
<div
class="w-16 h-16 rounded-full bg-neutral-800 flex items-center justify-center mb-4 border border-neutral-700"
>
<Icon icon="lucide:folder-open" class="w-8 h-8 text-neutral-500" />
</div>
<h2 class="text-white font-semibold text-lg mb-1">No projects yet</h2>
<p class="text-neutral-500 text-sm mb-6">
Create your first project to get started.
</p>
<button
onclick={openModal}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-md transition-colors"
>
<Icon icon="lucide:plus" class="w-4 h-4" />
New Project
</button>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each projects as project (project.id)}
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-6 hover:border-blue-500 transition-colors shadow-sm group flex flex-col"
>
<div class="flex justify-between items-start mb-3">
<a
href="/board/{project.id}"
class="text-xl font-semibold text-white group-hover:text-blue-400 transition-colors leading-tight"
>{project.name}</a
>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ml-2 shrink-0 {statusClass(
project,
)}"
>
{statusLabel(project)}
</span>
</div>
{#if project.team_name}
<p class="text-xs text-neutral-500 mb-2 flex items-center gap-1">
<Icon icon="lucide:users" class="w-3 h-3" />
{project.team_name}
</p>
{/if}
<p class="text-neutral-400 text-sm mb-6 line-clamp-2 flex-1">
{project.description || "No description"}
</p>
<div class="flex items-center justify-between mt-auto">
<div class="text-xs text-neutral-500">
Updated {new Date(project.updated_at).toLocaleDateString(
"en-US",
{ month: "2-digit", day: "2-digit", year: "numeric" },
)}
</div>
<div class="flex items-center gap-2">
<a
href="/projects/{project.id}/calendar"
class="text-neutral-400 hover:text-white text-xs flex items-center gap-1"
title="Calendar"
>
<Icon icon="lucide:calendar" class="w-4 h-4" />
</a>
{#if !project.team_name}
<a
href="/projects/{project.id}/files"
class="text-neutral-400 hover:text-white text-xs flex items-center gap-1"
title="Files"
>
<Icon icon="lucide:paperclip" class="w-4 h-4" />
</a>
{/if}
<a
href="/projects/{project.id}/settings"
class="text-neutral-400 hover:text-white text-xs flex items-center gap-1"
title="Settings"
>
<Icon icon="lucide:settings" class="w-4 h-4" />
</a>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if showModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div
class="bg-neutral-800 border border-neutral-700 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6"
>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-white">New Project</h2>
<button
onclick={closeModal}
class="text-neutral-400 hover:text-white transition-colors p-1 rounded"
>
<Icon icon="lucide:x" class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div>
<label
for="proj-name"
class="block text-sm font-medium text-neutral-300 mb-1.5"
>Project name</label
>
<input
id="proj-name"
type="text"
bind:value={newName}
placeholder="e.g. Website Redesign"
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label
for="proj-desc"
class="block text-sm font-medium text-neutral-300 mb-1.5"
>Description <span class="text-neutral-500 font-normal"
>(optional)</span
></label
>
<textarea
id="proj-desc"
bind:value={newDesc}
placeholder="What is this project about?"
rows="3"
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
></textarea>
</div>
<div>
<label
for="proj-team"
class="block text-sm font-medium text-neutral-300 mb-1.5"
>Team <span class="text-neutral-500 font-normal">(optional)</span
></label
>
<select
id="proj-team"
bind:value={selectedTeamId}
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Personal (no team)</option>
{#each teams as team (team.id)}
<option value={team.id}>{team.name}</option>
{/each}
</select>
<p class="text-xs text-neutral-500 mt-1">
Personal projects are only visible to you.
</p>
</div>
{#if createError}
<p class="text-red-400 text-sm">{createError}</p>
{/if}
</div>
<div class="flex justify-end gap-3 mt-6">
<button
onclick={closeModal}
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded-md transition-colors"
>
Cancel
</button>
<button
onclick={submitCreate}
disabled={creating}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors flex items-center gap-2"
>
{#if creating}
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8z"
></path></svg
>
{/if}
Create Project
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import Calendar from "$lib/components/Calendar/Calendar.svelte";
import { projects as projectsApi, events as eventsApi } from "$lib/api";
import type { Event } from "$lib/types/api";
let projectId = $derived($page.params.id ?? "");
let rawEvents = $state<Event[]>([]);
let loading = $state(true);
let isModalOpen = $state(false);
let saving = $state(false);
let error = $state("");
let newEvent = $state({ title: "", description: "", date: "", time: "" });
let calendarEvents = $derived(
rawEvents.map((e) => {
const dt = new Date(e.start_time);
const hours = dt.getHours();
const minutes = dt.getMinutes().toString().padStart(2, "0");
const ampm = hours >= 12 ? "PM" : "AM";
const h = hours % 12 || 12;
return {
id: e.id,
date: e.start_time.split("T")[0],
title: e.title,
time: `${h}:${minutes} ${ampm}`,
color: "blue",
description: e.description,
};
}),
);
onMount(async () => {
try {
rawEvents = await projectsApi.listEvents(projectId);
} catch {
rawEvents = [];
} finally {
loading = false;
}
});
async function addEvent(e: SubmitEvent) {
e.preventDefault();
if (!newEvent.title || !newEvent.date) return;
saving = true;
error = "";
try {
const startTime = newEvent.time
? new Date(`${newEvent.date}T${newEvent.time}`).toISOString()
: new Date(`${newEvent.date}T00:00:00`).toISOString();
const created = await projectsApi.createEvent(projectId, {
title: newEvent.title,
description: newEvent.description,
start_time: startTime,
end_time: startTime,
});
rawEvents = [...rawEvents, created];
isModalOpen = false;
newEvent = { title: "", description: "", date: "", time: "" };
} catch {
error = "Failed to create event.";
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Project Calendar — FPMB</title>
<meta
name="description"
content="View and manage events and milestones for this project's calendar in FPMB."
/>
</svelte:head>
<div class="flex flex-col -m-6 p-6">
<header
class="flex flex-col md:flex-row md:items-center justify-between mb-6 pb-6 border-b border-neutral-700 shrink-0 gap-4"
>
<div class="flex items-center space-x-4">
<a
href="/projects"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
</a>
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
Project Calendar
</h1>
<div class="text-sm text-neutral-400 flex items-center space-x-2 mt-1">
<a
href="/projects/{projectId}/calendar"
class="hover:text-blue-400 transition-colors">Overview</a
>
<span>/</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<button
onclick={() => (isModalOpen = true)}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
>
<Icon icon="lucide:plus" class="w-4 h-4 mr-2" />
Add Event
</button>
</div>
</header>
{#if loading}
<div class="flex-1 flex items-center justify-center text-neutral-400">
Loading events...
</div>
{:else}
<Calendar events={calendarEvents} />
{/if}
</div>
{#if isModalOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0" onclick={() => (isModalOpen = false)}></div>
<div
class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full max-w-md"
>
<div
class="flex items-center justify-between p-4 border-b border-neutral-700"
>
<h2 class="text-lg font-semibold text-white">Add Event</h2>
<button
onclick={() => (isModalOpen = false)}
class="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-700"
>
<Icon icon="lucide:x" class="w-5 h-5" />
</button>
</div>
<form onsubmit={addEvent} class="p-6 space-y-4">
{#if error}
<p class="text-sm text-red-400">{error}</p>
{/if}
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Title</label
>
<input
type="text"
bind:value={newEvent.title}
required
placeholder="Event title"
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Description</label
>
<textarea
bind:value={newEvent.description}
rows="2"
placeholder="Optional description"
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm resize-none"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Date</label
>
<input
type="date"
bind:value={newEvent.date}
required
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Time</label
>
<input
type="time"
bind:value={newEvent.time}
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
</div>
<div class="flex justify-end pt-2 gap-3">
<button
type="button"
onclick={() => (isModalOpen = false)}
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-colors disabled:opacity-50"
>
{saving ? "Saving..." : "Save Event"}
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,391 @@
<script lang="ts">
import { page } from "$app/stores";
import { projects as projectsApi, files as filesApi } from "$lib/api";
import type { FileItem } from "$lib/types/api";
import FileViewer from "$lib/components/FileViewer/FileViewer.svelte";
let projectId = $derived($page.params.id ?? "");
let folderStack = $state<{ id: string; name: string }[]>([]);
let currentParentId = $derived(
folderStack.length > 0 ? folderStack[folderStack.length - 1].id : "",
);
let fileList = $state<FileItem[]>([]);
let loading = $state(true);
let folderName = $state("");
let showFolderInput = $state(false);
let savingFolder = $state(false);
async function loadFiles(parentId: string) {
loading = true;
try {
fileList = await projectsApi.listFiles(projectId, parentId);
} catch {
fileList = [];
} finally {
loading = false;
}
}
$effect(() => {
loadFiles(currentParentId);
});
function formatSize(bytes: number): string {
if (!bytes) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
}
async function createFolder(e: SubmitEvent) {
e.preventDefault();
if (!folderName.trim()) return;
savingFolder = true;
try {
const created = await projectsApi.createFolder(
projectId,
folderName.trim(),
currentParentId,
);
fileList = [created, ...fileList];
folderName = "";
showFolderInput = false;
} catch {
} finally {
savingFolder = false;
}
}
async function handleUpload(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const created = await projectsApi.uploadFile(
projectId,
file,
currentParentId,
);
fileList = [...fileList, created];
} catch {}
input.value = "";
}
async function deleteFile(id: string) {
if (!confirm("Delete this item?")) return;
try {
await filesApi.delete(id);
fileList = fileList.filter((f) => f.id !== id);
} catch {}
}
function openFolder(folder: FileItem) {
folderStack = [...folderStack, { id: folder.id, name: folder.name }];
}
function navigateToBreadcrumb(index: number) {
if (index === -1) {
folderStack = [];
} else {
folderStack = folderStack.slice(0, index + 1);
}
}
function getIcon(type: string) {
if (type === "folder") {
return `<svg class="w-6 h-6 text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path></svg>`;
}
return `<svg class="w-6 h-6 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>`;
}
let viewingFile = $state<FileItem | null>(null);
let fileInput: HTMLInputElement;
</script>
<svelte:head>
<title>Project Files — FPMB</title>
<meta
name="description"
content="Browse, upload, and organise files and folders for this project in FPMB."
/>
</svelte:head>
<div class="h-full flex flex-col -m-6 p-6 overflow-hidden">
<header
class="flex flex-col md:flex-row md:items-center justify-between mb-6 pb-6 border-b border-neutral-700 shrink-0 gap-4"
>
<div class="flex items-center space-x-4">
<a
href="/projects"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
></path></svg
>
</a>
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
Project Files
</h1>
<div
class="text-sm text-neutral-400 flex items-center space-x-2 mt-1 flex-wrap gap-y-1"
>
<button
onclick={() => navigateToBreadcrumb(-1)}
class="hover:text-blue-400 transition-colors">Root</button
>
{#each folderStack as crumb, i}
<span>/</span>
<button
onclick={() => navigateToBreadcrumb(i)}
class="hover:text-blue-400 transition-colors">{crumb.name}</button
>
{/each}
<span>/</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<button
onclick={() => (showFolderInput = !showFolderInput)}
class="bg-neutral-800 hover:bg-neutral-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors text-sm flex items-center"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
></path></svg
>
New Folder
</button>
<input
bind:this={fileInput}
type="file"
class="hidden"
onchange={handleUpload}
/>
<button
onclick={() => fileInput.click()}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
></path></svg
>
Upload
</button>
</div>
</header>
{#if showFolderInput}
<form onsubmit={createFolder} class="mb-4 flex gap-2">
<input
type="text"
bind:value={folderName}
placeholder="Folder name"
required
autofocus
class="flex-1 px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<button
type="submit"
disabled={savingFolder}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50"
>
{savingFolder ? "Creating..." : "Create"}
</button>
<button
type="button"
onclick={() => (showFolderInput = false)}
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
>
Cancel
</button>
</form>
{/if}
<div
class="flex-1 overflow-auto bg-neutral-800 rounded-lg shadow-sm border border-neutral-700"
>
{#if loading}
<div class="p-12 text-center text-neutral-400">Loading files...</div>
{:else}
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-neutral-850 border-b border-neutral-700">
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Name</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-32 hidden sm:table-cell"
>Size</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-40 hidden md:table-cell"
>Last Modified</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider text-right w-24"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{#each fileList as file (file.id)}
<tr
class="hover:bg-neutral-750 transition-colors group cursor-pointer"
ondblclick={() =>
file.type === "folder"
? openFolder(file)
: (viewingFile = file)}
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="shrink-0 flex items-center justify-center">
{@html getIcon(file.type)}
</div>
<div class="ml-4">
{#if file.type === "folder"}
<button
onclick={() => openFolder(file)}
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors text-left"
>{file.name}</button
>
{:else}
<div
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors"
>
{file.name}
</div>
{/if}
<div class="text-xs text-neutral-500 sm:hidden mt-1">
{formatSize(file.size_bytes)}{formatDate(
file.updated_at,
)}
</div>
</div>
</div>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden sm:table-cell"
>
{formatSize(file.size_bytes)}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden md:table-cell"
>
{formatDate(file.updated_at)}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
>
<div
class="flex items-center justify-end space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if file.type === "file" && file.storage_url}
<button
onclick={() => filesApi.download(file.id, file.name)}
class="text-neutral-400 hover:text-white p-1 rounded"
title="Download"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
></path></svg
>
</button>
{/if}
<button
onclick={() => deleteFile(file.id)}
class="text-neutral-400 hover:text-red-400 p-1 rounded"
title="Delete"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path></svg
>
</button>
</div>
</td>
</tr>
{/each}
{#if fileList.length === 0}
<tr>
<td colspan="4" class="px-6 py-12 text-center text-neutral-400">
<div class="flex flex-col items-center">
<svg
class="w-12 h-12 text-neutral-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
></path></svg
>
<p>This folder is empty.</p>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
{/if}
</div>
</div>
<FileViewer bind:file={viewingFile} downloadUrl={filesApi.downloadUrl} />

View File

@@ -0,0 +1,234 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { projects as projectsApi } from "$lib/api";
import type { Project } from "$lib/types/api";
let projectId = $derived($page.params.id ?? "");
let project = $state<Project | null>(null);
let loading = $state(true);
let saving = $state(false);
let saveError = $state("");
let saveSuccess = $state(false);
let projectName = $state("");
let projectDescription = $state("");
onMount(async () => {
try {
project = await projectsApi.get(projectId);
projectName = project.name;
projectDescription = project.description;
} catch {
} finally {
loading = false;
}
});
async function saveSettings(e: SubmitEvent) {
e.preventDefault();
saving = true;
saveError = "";
saveSuccess = false;
try {
project = await projectsApi.update(projectId, {
name: projectName,
description: projectDescription,
});
saveSuccess = true;
setTimeout(() => (saveSuccess = false), 3000);
} catch (err: unknown) {
saveError =
err instanceof Error ? err.message : "Failed to save changes.";
} finally {
saving = false;
}
}
async function archiveProject() {
if (!confirm("Archive this project? It will become read-only.")) return;
try {
await projectsApi.archive(projectId);
goto("/projects");
} catch {}
}
async function deleteProject() {
if (
!confirm(
"Permanently delete this project and all its data? This cannot be undone.",
)
)
return;
try {
await projectsApi.delete(projectId);
goto("/projects");
} catch {}
}
</script>
<svelte:head>
<title
>{projectName
? `${projectName} Settings FPMB`
: "Project Settings — FPMB"}</title
>
<meta
name="description"
content="Configure project name, description, archive state, and danger zone settings in FPMB."
/>
</svelte:head>
<div class="max-w-4xl mx-auto space-y-10">
<div class="flex items-center space-x-4 mb-2">
<a
href="/projects"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
></path></svg
>
</a>
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">
Project Settings
</h1>
<p class="text-neutral-400 mt-1">
Configure {projectName || "..."} preferences and access.
</p>
</div>
</div>
<div class="border-b border-neutral-700 mb-8">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<a
href="/projects/{projectId}/settings"
class="border-blue-500 text-blue-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
>
General Settings
</a>
<a
href="/projects/{projectId}/webhooks"
class="border-transparent text-neutral-400 hover:text-white hover:border-neutral-500 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
>
Webhooks & Integrations
</a>
</nav>
</div>
{#if loading}
<div class="text-neutral-400 py-12 text-center">Loading...</div>
{:else}
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div class="p-6 border-b border-neutral-700">
<h2 class="text-xl font-semibold text-white mb-1">General Info</h2>
<p class="text-sm text-neutral-400">
Update project name and description.
</p>
</div>
<form onsubmit={saveSettings} class="p-6 space-y-6">
{#if saveError}
<p class="text-sm text-red-400">{saveError}</p>
{/if}
{#if saveSuccess}
<p class="text-sm text-green-400">Changes saved.</p>
{/if}
<div>
<label
for="projectName"
class="block text-sm font-medium text-neutral-300"
>Project Name</label
>
<input
type="text"
id="projectName"
bind:value={projectName}
required
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
<div>
<label
for="projectDescription"
class="block text-sm font-medium text-neutral-300"
>Description</label
>
<textarea
id="projectDescription"
bind:value={projectDescription}
rows="3"
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white resize-none"
></textarea>
</div>
<div class="flex justify-end pt-4 border-t border-neutral-700 mt-6">
<button
type="submit"
disabled={saving}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</section>
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-red-900 overflow-hidden"
>
<div class="p-6 border-b border-red-900 bg-red-900/10">
<h2 class="text-xl font-semibold text-red-500 mb-1">Danger Zone</h2>
<p class="text-sm text-neutral-400">
Irreversible destructive actions.
</p>
</div>
<div class="p-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-white">Archive Project</h3>
<p class="text-xs text-neutral-400 mt-1">
Mark this project as read-only and hide it from the active lists.
</p>
</div>
<button
onclick={archiveProject}
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors text-sm"
>
Archive
</button>
</div>
<div
class="border-t border-neutral-700 pt-6 flex items-center justify-between"
>
<div>
<h3 class="text-sm font-medium text-red-400">Delete Project</h3>
<p class="text-xs text-neutral-400 mt-1">
Permanently remove this project, its boards, files, and all
associated data.
</p>
</div>
<button
onclick={deleteProject}
class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm"
>
Delete Permanently
</button>
</div>
</div>
</section>
{/if}
</div>

View File

@@ -0,0 +1,372 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { projects as projectsApi, webhooks as webhooksApi } from "$lib/api";
import type { Webhook } from "$lib/types/api";
let projectId = $derived($page.params.id ?? "");
let webhookList = $state<Webhook[]>([]);
let loading = $state(true);
let isModalOpen = $state(false);
let saving = $state(false);
let newWebhook = $state({ name: "", type: "discord", url: "", secret: "" });
onMount(async () => {
try {
webhookList = await projectsApi.listWebhooks(projectId);
} catch {
webhookList = [];
} finally {
loading = false;
}
});
async function addWebhook(e: SubmitEvent) {
e.preventDefault();
if (!newWebhook.name || !newWebhook.url) return;
saving = true;
try {
const created = await projectsApi.createWebhook(projectId, {
name: newWebhook.name,
url: newWebhook.url,
events: ["*"],
});
webhookList = [...webhookList, created];
isModalOpen = false;
newWebhook = { name: "", type: "discord", url: "", secret: "" };
} catch {
} finally {
saving = false;
}
}
async function toggleStatus(id: string) {
try {
const updated = await webhooksApi.toggle(id);
webhookList = webhookList.map((w) => (w.id === id ? updated : w));
} catch {}
}
async function deleteWebhook(id: string) {
try {
await webhooksApi.delete(id);
webhookList = webhookList.filter((w) => w.id !== id);
} catch {}
}
function getWebhookType(url: string): string {
if (url.includes("discord.com")) return "discord";
if (url.includes("github.com")) return "github";
if (url.includes("gitea") || url.includes("git.")) return "gitea";
if (url.includes("slack.com")) return "slack";
return "custom";
}
function getIcon(type: string) {
switch (type) {
case "discord":
return "simple-icons:discord";
case "github":
return "simple-icons:github";
case "gitea":
return "simple-icons:gitea";
case "slack":
return "simple-icons:slack";
default:
return "lucide:webhook";
}
}
function getColor(type: string) {
switch (type) {
case "discord":
return "text-[#5865F2]";
case "github":
return "text-white";
case "gitea":
return "text-[#609926]";
case "slack":
return "text-[#E01E5A]";
default:
return "text-neutral-400";
}
}
function formatLastTriggered(iso: string): string {
if (!iso || iso === "0001-01-01T00:00:00Z") return "Never";
const d = new Date(iso);
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
</script>
<svelte:head>
<title>Webhooks & Integrations — FPMB</title>
<meta
name="description"
content="Configure webhooks and integrations with Discord, GitHub, Gitea, Slack, and custom endpoints for this project."
/>
</svelte:head>
<div class="max-w-4xl mx-auto space-y-10">
<div class="flex items-center space-x-4 mb-2">
<a
href="/projects/{projectId}/settings"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
</a>
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">
Webhooks & Integrations
</h1>
<p class="text-neutral-400 mt-1">
Connect your project with external tools and services.
</p>
</div>
</div>
<div class="border-b border-neutral-700 mb-8">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<a
href="/projects/{projectId}/settings"
class="border-transparent text-neutral-400 hover:text-white hover:border-neutral-500 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
>
General Settings
</a>
<a
href="/projects/{projectId}/webhooks"
class="border-blue-500 text-blue-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
>
Webhooks & Integrations
</a>
</nav>
</div>
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div
class="p-6 border-b border-neutral-700 flex justify-between items-center"
>
<div>
<h2 class="text-xl font-semibold text-white mb-1">
Configured Webhooks
</h2>
<p class="text-sm text-neutral-400">
Trigger actions in other apps when events occur in FPMB.
</p>
</div>
<button
onclick={() => (isModalOpen = true)}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
>
<Icon icon="lucide:plus" class="w-4 h-4 mr-2" />
Add Webhook
</button>
</div>
{#if loading}
<div class="p-12 text-center text-neutral-400">Loading webhooks...</div>
{:else if webhookList.length === 0}
<div class="p-12 text-center flex flex-col items-center justify-center">
<Icon icon="lucide:webhook" class="w-12 h-12 text-neutral-600 mb-4" />
<h3 class="text-lg font-medium text-white mb-1">No Webhooks Yet</h3>
<p class="text-neutral-400 text-sm">
Add a webhook to start receiving automated updates in Discord, GitHub,
and more.
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-neutral-850 border-b border-neutral-700">
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Integration</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider hidden md:table-cell"
>Target URL</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Status</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider text-right"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{#each webhookList as webhook (webhook.id)}
{@const wtype = getWebhookType(webhook.url)}
<tr class="hover:bg-neutral-750 transition-colors group">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div
class="shrink-0 flex items-center justify-center w-8 h-8 rounded bg-neutral-900 border border-neutral-700"
>
<Icon
icon={getIcon(wtype)}
class="w-5 h-5 {getColor(wtype)}"
/>
</div>
<div class="ml-4">
<div
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors"
>
{webhook.name}
</div>
<div class="text-xs text-neutral-500 mt-1 capitalize">
{wtype} • Last: {formatLastTriggered(
webhook.last_triggered,
)}
</div>
</div>
</div>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden md:table-cell max-w-[200px] truncate"
>
{webhook.url}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<button
onclick={() => toggleStatus(webhook.id)}
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border {webhook.active
? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-neutral-700 text-neutral-400 border-neutral-600'}"
>
{webhook.active ? "Active" : "Inactive"}
</button>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
>
<div class="flex items-center justify-end space-x-2">
<button
onclick={() => deleteWebhook(webhook.id)}
class="text-neutral-400 hover:text-red-400 p-2 rounded hover:bg-neutral-700 transition-colors"
title="Delete"
>
<Icon icon="lucide:trash-2" class="w-4 h-4" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</div>
{#if isModalOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0" onclick={() => (isModalOpen = false)}></div>
<div
class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full max-w-lg"
>
<div
class="flex items-center justify-between p-4 border-b border-neutral-700"
>
<h2 class="text-lg font-semibold text-white">Add Webhook</h2>
<button
onclick={() => (isModalOpen = false)}
class="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-700"
>
<Icon icon="lucide:x" class="w-5 h-5" />
</button>
</div>
<form onsubmit={addWebhook} class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Service Type</label
>
<select
bind:value={newWebhook.type}
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>
<option value="discord">Discord</option>
<option value="github">GitHub</option>
<option value="gitea">Gitea</option>
<option value="slack">Slack</option>
<option value="custom">Custom Webhook</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Name</label
>
<input
type="text"
bind:value={newWebhook.name}
required
placeholder="e.g. My Team Discord"
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Payload URL</label
>
<input
type="url"
bind:value={newWebhook.url}
required
placeholder="https://..."
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Secret Token (Optional)</label
>
<input
type="password"
bind:value={newWebhook.secret}
placeholder="Used to sign webhook payloads"
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div class="flex justify-end pt-4 gap-3">
<button
type="button"
onclick={() => (isModalOpen = false)}
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-colors disabled:opacity-50"
>
{saving ? "Saving..." : "Save Webhook"}
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,711 @@
<script lang="ts">
import { onMount } from "svelte";
import { users as usersApi, apiKeys as apiKeysApi } from "$lib/api";
import { authStore } from "$lib/stores/auth.svelte";
import type { ApiKey, ApiKeyCreated } from "$lib/types/api";
// --- Profile state ---
let name = $state("");
let email = $state("");
let currentPassword = $state("");
let newPassword = $state("");
let confirmPassword = $state("");
let profileLoading = $state(false);
let passwordLoading = $state(false);
let avatarLoading = $state(false);
let profileError = $state("");
let profileSuccess = $state("");
let passwordError = $state("");
let passwordSuccess = $state("");
let avatarError = $state("");
let avatarSuccess = $state("");
// --- API Keys state ---
const ALL_SCOPES = [
{ id: "read:projects", label: "Read Projects", group: "Projects" },
{ id: "write:projects", label: "Write Projects", group: "Projects" },
{ id: "read:boards", label: "Read Boards", group: "Boards" },
{ id: "write:boards", label: "Write Boards", group: "Boards" },
{ id: "read:teams", label: "Read Teams", group: "Teams" },
{ id: "write:teams", label: "Write Teams", group: "Teams" },
{ id: "read:files", label: "Read Files", group: "Files" },
{ id: "write:files", label: "Write Files", group: "Files" },
{
id: "read:notifications",
label: "Read Notifications",
group: "Notifications",
},
];
let apiKeyList = $state<ApiKey[]>([]);
let apiKeysLoading = $state(true);
let newKeyName = $state("");
let newKeyScopes = $state<Record<string, boolean>>({});
let creatingKey = $state(false);
let newKeyError = $state("");
let createdKey = $state<ApiKeyCreated | null>(null);
let copiedKey = $state(false);
let showCreateForm = $state(false);
onMount(async () => {
if (authStore.user) {
name = authStore.user.name;
email = authStore.user.email;
}
try {
apiKeyList = await apiKeysApi.list();
} catch {
apiKeyList = [];
} finally {
apiKeysLoading = false;
}
});
let userInitial = $derived(name?.charAt(0).toUpperCase() ?? "U");
// --- Profile handlers ---
async function uploadAvatar(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
avatarLoading = true;
avatarError = "";
avatarSuccess = "";
try {
const updated = await usersApi.uploadAvatar(file);
authStore.setUser(updated);
avatarSuccess = "Avatar updated successfully.";
} catch (err: unknown) {
avatarError =
err instanceof Error ? err.message : "Failed to upload avatar";
} finally {
avatarLoading = false;
input.value = "";
}
}
async function saveProfile(e: Event) {
e.preventDefault();
profileLoading = true;
profileError = "";
profileSuccess = "";
try {
const updated = await usersApi.updateMe({ name, email });
authStore.setUser(updated);
profileSuccess = "Profile updated successfully.";
} catch (err: unknown) {
profileError = err instanceof Error ? err.message : "Failed to save";
} finally {
profileLoading = false;
}
}
async function savePassword(e: Event) {
e.preventDefault();
if (newPassword !== confirmPassword) {
passwordError = "Passwords do not match";
return;
}
passwordLoading = true;
passwordError = "";
passwordSuccess = "";
try {
await usersApi.changePassword(currentPassword, newPassword);
currentPassword = "";
newPassword = "";
confirmPassword = "";
passwordSuccess = "Password updated successfully.";
} catch (err: unknown) {
passwordError =
err instanceof Error ? err.message : "Failed to update password";
} finally {
passwordLoading = false;
}
}
// --- API Key handlers ---
function openCreateForm() {
newKeyName = "";
newKeyScopes = {};
newKeyError = "";
createdKey = null;
showCreateForm = true;
}
function closeCreateForm() {
showCreateForm = false;
}
let selectedScopeCount = $derived(
Object.values(newKeyScopes).filter(Boolean).length,
);
async function createKey(e: Event) {
e.preventDefault();
const scopes = ALL_SCOPES.filter((s) => newKeyScopes[s.id]).map(
(s) => s.id,
);
if (!newKeyName.trim()) {
newKeyError = "Name is required.";
return;
}
if (scopes.length === 0) {
newKeyError = "Select at least one scope.";
return;
}
creatingKey = true;
newKeyError = "";
try {
const result = await apiKeysApi.create(newKeyName.trim(), scopes);
createdKey = result;
apiKeyList = await apiKeysApi.list();
newKeyName = "";
newKeyScopes = {};
showCreateForm = false;
} catch (err: unknown) {
newKeyError =
err instanceof Error ? err.message : "Failed to create key.";
} finally {
creatingKey = false;
}
}
async function revokeKey(keyId: string) {
if (!confirm("Revoke this API key? Any apps using it will lose access."))
return;
try {
await apiKeysApi.revoke(keyId);
apiKeyList = apiKeyList.filter((k) => k.id !== keyId);
if (createdKey?.id === keyId) createdKey = null;
} catch {
/* ignore */
}
}
async function copyKey() {
if (!createdKey) return;
await navigator.clipboard.writeText(createdKey.key);
copiedKey = true;
setTimeout(() => (copiedKey = false), 2500);
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
const scopeGroups = ALL_SCOPES.reduce<Record<string, typeof ALL_SCOPES>>(
(acc, s) => {
(acc[s.group] ??= []).push(s);
return acc;
},
{},
);
</script>
<svelte:head>
<title>User Settings — FPMB</title>
<meta
name="description"
content="Manage your FPMB profile, avatar, account password, and API keys."
/>
</svelte:head>
<div class="max-w-4xl mx-auto space-y-10">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight mb-2">
User Settings
</h1>
<p class="text-neutral-400">Manage your profile and account preferences.</p>
</div>
<!-- Profile Section -->
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div class="p-6 border-b border-neutral-700">
<h2 class="text-xl font-semibold text-white mb-1">Profile Information</h2>
<p class="text-sm text-neutral-400">
Update your account's profile information and email address.
</p>
</div>
<form onsubmit={saveProfile} class="p-6 space-y-6">
{#if profileError}
<div
class="rounded-md bg-red-900/50 border border-red-700 p-3 text-sm text-red-300"
>
{profileError}
</div>
{/if}
{#if profileSuccess}
<div
class="rounded-md bg-green-900/50 border border-green-700 p-3 text-sm text-green-300"
>
{profileSuccess}
</div>
{/if}
<div class="flex items-center space-x-6">
<div class="shrink-0">
{#if authStore.user?.avatar_url}
<img
src={authStore.user.avatar_url}
alt="Avatar"
class="h-16 w-16 rounded-full object-cover shadow-inner"
/>
{:else}
<div
class="h-16 w-16 rounded-full bg-blue-600 flex items-center justify-center text-xl font-medium text-white shadow-inner"
>
{userInitial}
</div>
{/if}
</div>
<div class="space-y-2">
{#if avatarError}<p class="text-sm text-red-400">
{avatarError}
</p>{/if}
{#if avatarSuccess}<p class="text-sm text-green-400">
{avatarSuccess}
</p>{/if}
<label
class="cursor-pointer inline-flex items-center gap-2 bg-neutral-700 hover:bg-neutral-600 text-white text-sm font-medium py-1.5 px-4 rounded-md border border-neutral-600 transition-colors {avatarLoading
? 'opacity-50 pointer-events-none'
: ''}"
>
{avatarLoading ? "Uploading..." : "Upload Avatar"}
<input
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp"
class="hidden"
onchange={uploadAvatar}
disabled={avatarLoading}
/>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-neutral-300"
>Full Name</label
>
<input
type="text"
id="name"
bind:value={name}
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-neutral-300"
>Email Address</label
>
<input
type="email"
id="email"
bind:value={email}
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div class="flex justify-end pt-4 border-t border-neutral-700 mt-6">
<button
type="submit"
disabled={profileLoading}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50"
>
{profileLoading ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</section>
<!-- Security Section -->
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div class="p-6 border-b border-neutral-700">
<h2 class="text-xl font-semibold text-white mb-1">Update Password</h2>
<p class="text-sm text-neutral-400">
Ensure your account is using a long, random password to stay secure.
</p>
</div>
<form onsubmit={savePassword} class="p-6 space-y-6">
{#if passwordError}
<div
class="rounded-md bg-red-900/50 border border-red-700 p-3 text-sm text-red-300"
>
{passwordError}
</div>
{/if}
{#if passwordSuccess}
<div
class="rounded-md bg-green-900/50 border border-green-700 p-3 text-sm text-green-300"
>
{passwordSuccess}
</div>
{/if}
<div class="max-w-md space-y-4">
<div>
<label
for="current_password"
class="block text-sm font-medium text-neutral-300"
>Current Password</label
>
<input
type="password"
id="current_password"
bind:value={currentPassword}
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
<div>
<label
for="new_password"
class="block text-sm font-medium text-neutral-300"
>New Password</label
>
<input
type="password"
id="new_password"
bind:value={newPassword}
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
<div>
<label
for="confirm_password"
class="block text-sm font-medium text-neutral-300"
>Confirm Password</label
>
<input
type="password"
id="confirm_password"
bind:value={confirmPassword}
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div class="flex justify-end pt-4 border-t border-neutral-700 mt-6">
<button
type="submit"
disabled={passwordLoading}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50"
>
{passwordLoading ? "Updating..." : "Update Password"}
</button>
</div>
</form>
</section>
<!-- API Keys Section -->
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div
class="p-6 border-b border-neutral-700 flex items-center justify-between gap-4"
>
<div>
<h2 class="text-xl font-semibold text-white mb-1">API Keys</h2>
<p class="text-sm text-neutral-400">
Generate personal API keys with granular scopes for programmatic
access.
</p>
</div>
{#if !showCreateForm}
<button
onclick={openCreateForm}
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md text-sm transition-colors border border-transparent shrink-0"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/></svg
>
New API Key
</button>
{/if}
</div>
<!-- Newly-created key banner (shown once) -->
{#if createdKey}
<div
class="mx-6 mt-6 rounded-lg border border-green-600/40 bg-green-900/20 p-4"
>
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-green-400 mt-0.5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-green-300 mb-2">
Key created — copy it now, it won't be shown again.
</p>
<div class="flex items-center gap-2">
<code
class="flex-1 text-xs font-mono bg-neutral-900 border border-neutral-600 rounded px-3 py-2 text-green-300 truncate select-all"
>{createdKey.key}</code
>
<button
onclick={copyKey}
class="shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-2 rounded-md border transition-colors {copiedKey
? 'bg-green-700 border-green-600 text-white'
: 'bg-neutral-700 border-neutral-600 text-neutral-200 hover:bg-neutral-600'}"
>
{#if copiedKey}
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
>
Copied!
{:else}
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/></svg
>
Copy
{/if}
</button>
</div>
</div>
<button
onclick={() => (createdKey = null)}
class="text-neutral-500 hover:text-white transition-colors p-0.5 rounded"
aria-label="Dismiss banner"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</div>
</div>
{/if}
<!-- Create form -->
{#if showCreateForm}
<form
onsubmit={createKey}
class="p-6 space-y-6 border-b border-neutral-700"
>
<div>
<label
for="api-key-name"
class="block text-sm font-medium text-neutral-300 mb-1.5"
>Key Name</label
>
<input
id="api-key-name"
type="text"
bind:value={newKeyName}
placeholder="e.g. CI / CD Pipeline"
class="w-full max-w-sm px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<p class="text-sm font-medium text-neutral-300 mb-3">
Scopes <span class="text-neutral-500 font-normal"
>({selectedScopeCount} selected)</span
>
</p>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-5"
>
{#each Object.entries(scopeGroups) as [group, scopes]}
<div>
<p
class="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-2"
>
{group}
</p>
<div class="space-y-2">
{#each scopes as scope}
<label
class="flex items-center gap-2.5 cursor-pointer group"
for="scope-{scope.id}"
>
<input
type="checkbox"
id="scope-{scope.id}"
bind:checked={newKeyScopes[scope.id]}
class="w-4 h-4 rounded border-neutral-600 bg-neutral-700 text-blue-500 focus:ring-blue-500 focus:ring-offset-neutral-800 cursor-pointer"
/>
<span
class="text-sm text-neutral-300 group-hover:text-white transition-colors"
>{scope.label}</span
>
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
{#if newKeyError}
<p class="text-sm text-red-400">{newKeyError}</p>
{/if}
<div class="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={creatingKey}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-md text-sm transition-colors disabled:opacity-50 flex items-center gap-2"
>
{#if creatingKey}
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8z"
></path>
</svg>
{/if}
Generate Key
</button>
<button
type="button"
onclick={closeCreateForm}
class="text-sm font-medium text-neutral-400 hover:text-white transition-colors px-3 py-2 rounded hover:bg-neutral-700"
>
Cancel
</button>
</div>
</form>
{/if}
<!-- Key list -->
<div class="divide-y divide-neutral-700">
{#if apiKeysLoading}
<div class="p-8 text-center text-neutral-500 text-sm">
Loading keys...
</div>
{:else if apiKeyList.length === 0 && !createdKey}
<div
class="p-10 flex flex-col items-center justify-center text-neutral-500"
>
<svg
class="w-10 h-10 mb-3 opacity-40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<p class="text-sm">No API keys yet. Create one above.</p>
</div>
{:else}
{#each apiKeyList as key (key.id)}
<div
class="px-6 py-4 flex items-start justify-between gap-4 group hover:bg-white/[0.02] transition-colors"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1.5">
<span class="text-sm font-semibold text-white">{key.name}</span>
<code
class="text-xs font-mono bg-neutral-900 border border-neutral-700 text-neutral-400 px-2 py-0.5 rounded"
>{key.prefix}</code
>
</div>
<div class="flex flex-wrap gap-1.5 mb-2">
{#each key.scopes as scope}
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-900/40 text-blue-300 border border-blue-700/40"
>{scope}</span
>
{/each}
</div>
<p class="text-xs text-neutral-500">
Created {formatDate(key.created_at)}{#if key.last_used}
· Last used {formatDate(key.last_used)}{/if}
</p>
</div>
<button
onclick={() => revokeKey(key.id)}
class="shrink-0 flex items-center gap-1.5 text-xs font-medium text-neutral-500 hover:text-red-400 transition-colors px-2 py-1.5 rounded hover:bg-red-900/20 opacity-0 group-hover:opacity-100"
title="Revoke this key"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Revoke
</button>
</div>
{/each}
{/if}
</div>
</section>
</div>

View File

@@ -0,0 +1,541 @@
<script lang="ts">
import { page } from "$app/stores";
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { teams as teamsApi, board as boardApi } from "$lib/api";
import { RoleFlag, hasPermission } from "$lib/types/roles";
import type { Team, Project, TeamMember, Event, Doc } from "$lib/types/api";
let teamId = $derived($page.params.id ?? "");
let team = $state<Team | null>(null);
let members = $state<TeamMember[]>([]);
let recentProjects = $state<Project[]>([]);
let upcomingEvents = $state<Event[]>([]);
let cardEvents = $state<
{
id: string;
date: string;
title: string;
projectName: string;
projectId: string;
}[]
>([]);
let recentDocs = $state<Doc[]>([]);
let myRole = $state(0);
let loading = $state(true);
let showCreateProject = $state(false);
let newProjectName = $state("");
let newProjectDesc = $state("");
let creating = $state(false);
let createError = $state("");
let teamRoleName = $derived.by(() => {
if (hasPermission(myRole, RoleFlag.Owner)) return "Owner";
if (hasPermission(myRole, RoleFlag.Admin)) return "Admin";
if (hasPermission(myRole, RoleFlag.Editor)) return "Editor";
return "Viewer";
});
let canCreate = $derived(hasPermission(myRole, RoleFlag.Editor));
onMount(async () => {
try {
const [teamData, memberData, projectData, eventData, docData] =
await Promise.all([
teamsApi.get(teamId),
teamsApi.listMembers(teamId),
teamsApi.listProjects(teamId),
teamsApi.listEvents(teamId),
teamsApi.listDocs(teamId),
]);
team = teamData;
members = memberData;
recentProjects = projectData;
upcomingEvents = eventData;
recentDocs = docData;
const boards = await Promise.all(
projectData.map((p: Project) => boardApi.get(p.id).catch(() => null)),
);
cardEvents = boards.flatMap((b, i) => {
if (!b) return [];
return b.columns.flatMap((col) =>
(col.cards ?? [])
.filter((c) => c.due_date)
.map((c) => ({
id: c.id,
date: c.due_date.split("T")[0],
title: c.title,
projectName: projectData[i].name,
projectId: projectData[i].id,
})),
);
});
const stored =
typeof localStorage !== "undefined"
? localStorage.getItem("user_id")
: null;
const me = memberData.find((m) => m.user_id === stored);
if (me) myRole = me.role_flags;
} finally {
loading = false;
}
});
function openCreateProject() {
newProjectName = "";
newProjectDesc = "";
createError = "";
showCreateProject = true;
}
function closeCreateProject() {
showCreateProject = false;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
}
async function submitCreateProject() {
if (!newProjectName.trim()) {
createError = "Project name is required.";
return;
}
creating = true;
createError = "";
try {
const project = await teamsApi.createProject(
teamId,
newProjectName.trim(),
newProjectDesc.trim(),
);
recentProjects = [project, ...recentProjects];
showCreateProject = false;
} catch (e: unknown) {
createError =
e instanceof Error ? e.message : "Failed to create project.";
} finally {
creating = false;
}
}
</script>
<svelte:head>
<title>{team ? `${team.name} FPMB` : "Team — FPMB"}</title>
<meta
name="description"
content={team
? `Overview of the ${team.name} team projects, calendar, docs, and members.`
: "Team overview in FPMB."}
/>
</svelte:head>
<div class="max-w-7xl mx-auto space-y-8 h-full flex flex-col pb-8">
{#if loading}
<p class="text-neutral-500 text-sm">Loading...</p>
{:else if team}
<!-- Team Header -->
<header
class="bg-neutral-800 rounded-xl border border-neutral-700 shadow-sm overflow-hidden shrink-0 relative"
>
<div
class="h-32 bg-gradient-to-r from-blue-900/40 to-purple-900/40 relative"
>
{#if team.banner_url}
<img
src={team.banner_url}
alt="Team banner"
class="absolute inset-0 w-full h-full object-cover"
/>
{/if}
<div
class="absolute inset-0 bg-neutral-900 opacity-80"
style="background-image: radial-gradient(#333 1px, transparent 1px); background-size: 20px 20px;"
></div>
</div>
<div
class="px-8 pb-8 pt-4 relative flex flex-col md:flex-row md:items-end justify-between gap-6 -mt-16"
>
<div class="flex items-end gap-6">
<div
class="w-24 h-24 rounded-xl bg-neutral-800 border-4 border-neutral-900 flex items-center justify-center shadow-lg overflow-hidden shrink-0 relative z-10"
>
{#if team.avatar_url}
<img
src={team.avatar_url}
alt="Team avatar"
class="w-full h-full object-cover"
/>
{:else}
<div
class="w-full h-full bg-blue-600 flex items-center justify-center text-4xl font-bold text-white shadow-inner"
>
{team.name.charAt(0)}
</div>
{/if}
</div>
<div class="pb-2">
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold text-white tracking-tight">
{team.name}
</h1>
{#if myRole > 0}
<span
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-neutral-700 text-neutral-300 border border-neutral-600"
>
{teamRoleName}
</span>
{/if}
</div>
<p class="text-neutral-400 mt-1 flex items-center gap-2">
<Icon icon="lucide:users" class="w-4 h-4" />
{members.length} Members
</p>
</div>
</div>
<div class="flex items-center space-x-3 pb-2">
<a
href="/team/{teamId}/calendar"
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors flex items-center gap-2 text-sm"
>
<Icon icon="lucide:calendar-days" class="w-4 h-4" />
Calendar
</a>
<a
href="/team/{teamId}/docs"
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors flex items-center gap-2 text-sm"
>
<Icon icon="lucide:book-open" class="w-4 h-4" />
Docs
</a>
<a
href="/team/{teamId}/files"
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors flex items-center gap-2 text-sm"
>
<Icon icon="lucide:paperclip" class="w-4 h-4" />
Files
</a>
<a
href="/team/{teamId}/chat"
class="bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 font-medium py-2 px-4 rounded-md shadow-sm border border-blue-500/30 transition-colors flex items-center gap-2 text-sm"
>
<Icon icon="lucide:message-circle" class="w-4 h-4" />
Chat
</a>
{#if hasPermission(myRole, RoleFlag.Admin) || hasPermission(myRole, RoleFlag.Owner)}
<a
href="/team/{teamId}/settings"
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors flex items-center gap-2 text-sm"
>
<Icon icon="lucide:settings" class="w-4 h-4" />
Settings
</a>
{/if}
</div>
</div>
</header>
<!-- Widgets Grid -->
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 flex-1 items-start"
>
<!-- Projects Widget -->
<section
class="bg-neutral-800 rounded-lg border border-neutral-700 shadow-sm flex flex-col h-full col-span-1 md:col-span-2 lg:col-span-2"
>
<div
class="p-5 border-b border-neutral-700 flex items-center justify-between"
>
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<Icon icon="lucide:folder-open" class="w-5 h-5 text-blue-400" />
Active Projects
</h2>
<a
href="/projects"
class="text-sm font-medium text-blue-500 hover:text-blue-400 flex items-center gap-1 transition-colors"
>
View all <Icon icon="lucide:arrow-right" class="w-4 h-4" />
</a>
</div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
{#each recentProjects.slice(0, 4) as project (project.id)}
<a
href="/board/{project.id}"
class="block bg-neutral-750 p-4 rounded-md border border-neutral-600 hover:border-blue-500 hover:bg-neutral-700 transition-all group shadow-sm"
>
<div class="flex items-start justify-between mb-3">
<div
class="w-10 h-10 rounded bg-neutral-700 flex items-center justify-center text-blue-400 group-hover:scale-110 transition-transform"
>
<Icon icon="lucide:kanban-square" class="w-5 h-5" />
</div>
</div>
<h3
class="font-semibold text-white group-hover:text-blue-400 transition-colors"
>
{project.name}
</h3>
<p class="text-xs text-neutral-400 mt-1 flex items-center gap-1">
<Icon icon="lucide:clock" class="w-3 h-3" />
{formatDate(project.updated_at)}
</p>
</a>
{/each}
{#if canCreate}
<button
onclick={openCreateProject}
class="flex flex-col items-center justify-center p-4 rounded-md border-2 border-dashed border-neutral-700 text-neutral-500 hover:text-white hover:border-neutral-500 transition-colors bg-neutral-800/50 group h-full min-h-[140px]"
>
<Icon
icon="lucide:plus-circle"
class="w-8 h-8 mb-2 group-hover:scale-110 transition-transform"
/>
<span class="font-medium text-sm">New Project</span>
</button>
{/if}
</div>
</section>
<!-- Upcoming Events Widget -->
<section
class="bg-neutral-800 rounded-lg border border-neutral-700 shadow-sm flex flex-col h-full lg:col-span-1"
>
<div
class="p-5 border-b border-neutral-700 flex items-center justify-between"
>
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<Icon icon="lucide:calendar-days" class="w-5 h-5 text-green-400" />
Calendar
</h2>
<a
href="/team/{teamId}/calendar"
class="p-1.5 rounded-md text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="View Calendar"
>
<Icon icon="lucide:external-link" class="w-4 h-4" />
</a>
</div>
<div class="p-0 flex-1">
<ul class="divide-y divide-neutral-700">
{#each upcomingEvents.slice(0, 3) as event (event.id)}
<li
class="p-4 hover:bg-neutral-750 transition-colors cursor-pointer group"
>
<div class="flex items-start gap-3">
<div class="w-2 h-2 rounded-full mt-1.5 bg-blue-500"></div>
<div class="flex-1">
<h3
class="text-sm font-semibold text-white group-hover:text-blue-400 transition-colors"
>
{event.title}
</h3>
<p class="text-xs text-neutral-400 mt-0.5">{event.date}</p>
</div>
<Icon
icon="lucide:chevron-right"
class="w-4 h-4 text-neutral-600 group-hover:text-neutral-400 mt-1"
/>
</div>
</li>
{/each}
{#each cardEvents.slice(0, 3 - Math.min(upcomingEvents.length, 3)) as card (card.id)}
<li
class="p-4 hover:bg-neutral-750 transition-colors cursor-pointer group"
>
<a
href="/board/{card.projectId}"
class="flex items-start gap-3"
>
<div class="w-2 h-2 rounded-full mt-1.5 bg-yellow-500"></div>
<div class="flex-1">
<h3
class="text-sm font-semibold text-white group-hover:text-blue-400 transition-colors"
>
{card.title}
</h3>
<p class="text-xs text-neutral-400 mt-0.5">
{formatDate(card.date)} · {card.projectName}
</p>
</div>
<Icon
icon="lucide:chevron-right"
class="w-4 h-4 text-neutral-600 group-hover:text-neutral-400 mt-1"
/>
</a>
</li>
{/each}
{#if upcomingEvents.length === 0 && cardEvents.length === 0}
<li class="p-8 text-center text-neutral-500 text-sm">
No upcoming events.
</li>
{/if}
</ul>
</div>
<div class="p-4 border-t border-neutral-700 mt-auto">
<a
href="/team/{teamId}/calendar"
class="block w-full text-center py-2 bg-neutral-700 hover:bg-neutral-600 text-white rounded-md text-sm font-medium transition-colors border border-neutral-600"
>
Open Full Calendar
</a>
</div>
</section>
<!-- Recent Docs Widget -->
<section
class="bg-neutral-800 rounded-lg border border-neutral-700 shadow-sm flex flex-col h-full md:col-span-2 lg:col-span-3"
>
<div
class="p-5 border-b border-neutral-700 flex items-center justify-between"
>
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<Icon icon="lucide:book-open" class="w-5 h-5 text-purple-400" />
Team Knowledge Base
</h2>
<a
href="/team/{teamId}/docs"
class="p-1.5 rounded-md text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="View All Docs"
>
<Icon icon="lucide:external-link" class="w-4 h-4" />
</a>
</div>
<div class="p-5">
{#if recentDocs.length === 0}
<p class="text-neutral-500 text-sm">No docs yet.</p>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each recentDocs.slice(0, 6) as doc (doc.id)}
<a
href="/team/{teamId}/docs"
class="flex items-center gap-3 p-3 rounded-md bg-neutral-750 border border-neutral-600 hover:border-blue-500 hover:bg-neutral-700 transition-all group shadow-sm"
>
<div
class="w-10 h-10 rounded bg-neutral-800 flex items-center justify-center text-purple-400 group-hover:scale-110 transition-transform shadow-inner"
>
<Icon icon="lucide:file-text" class="w-5 h-5" />
</div>
<div class="flex-1 min-w-0">
<h3
class="text-sm font-semibold text-white truncate group-hover:text-blue-400 transition-colors"
>
{doc.title}
</h3>
<p
class="text-xs text-neutral-400 flex items-center gap-1 mt-0.5"
>
<Icon icon="lucide:clock" class="w-3 h-3" />
{formatDate(doc.updated_at)}
</p>
</div>
</a>
{/each}
</div>
{/if}
</div>
</section>
</div>
{/if}
</div>
{#if showCreateProject}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div
class="bg-neutral-800 border border-neutral-700 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6"
>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-white">New Project</h2>
<button
onclick={closeCreateProject}
class="text-neutral-400 hover:text-white transition-colors p-1 rounded"
>
<Icon icon="lucide:x" class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div>
<label
for="project-name"
class="block text-sm font-medium text-neutral-300 mb-1.5"
>Project name</label
>
<input
id="project-name"
type="text"
bind:value={newProjectName}
placeholder="e.g. Website Redesign"
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label
for="project-desc"
class="block text-sm font-medium text-neutral-300 mb-1.5"
>Description <span class="text-neutral-500 font-normal"
>(optional)</span
></label
>
<textarea
id="project-desc"
bind:value={newProjectDesc}
placeholder="What is this project about?"
rows="3"
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
></textarea>
</div>
{#if createError}
<p class="text-red-400 text-sm">{createError}</p>
{/if}
</div>
<div class="flex justify-end gap-3 mt-6">
<button
onclick={closeCreateProject}
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded-md transition-colors"
>
Cancel
</button>
<button
onclick={submitCreateProject}
disabled={creating}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors flex items-center gap-2"
>
{#if creating}
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8z"
></path></svg
>
{/if}
Create Project
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import Calendar from "$lib/components/Calendar/Calendar.svelte";
import { teams as teamsApi, board as boardApi } from "$lib/api";
import type { Event, Project } from "$lib/types/api";
let teamId = $derived($page.params.id ?? "");
let teamEvents = $state<Event[]>([]);
let cardEvents = $state<
{
id: string;
date: string;
title: string;
time: string;
color: string;
description: string;
}[]
>([]);
let loading = $state(true);
let isModalOpen = $state(false);
let saving = $state(false);
let error = $state("");
let newEvent = $state({
title: "",
date: "",
time: "",
color: "blue",
description: "",
});
const priorityColor: Record<string, string> = {
Low: "neutral",
Medium: "blue",
High: "yellow",
Urgent: "red",
};
let calendarEvents = $derived([
...teamEvents.map((e) => ({
id: e.id,
date: e.date,
title: e.title,
time: e.time,
color: e.color,
description: e.description,
})),
...cardEvents,
]);
onMount(async () => {
try {
const [events, projects] = await Promise.all([
teamsApi.listEvents(teamId),
teamsApi.listProjects(teamId),
]);
teamEvents = events;
const boards = await Promise.all(
(projects as Project[]).map((p) =>
boardApi.get(p.id).catch(() => null),
),
);
cardEvents = boards.flatMap((b, i) => {
if (!b) return [];
return b.columns.flatMap((col) =>
(col.cards ?? [])
.filter((c) => c.due_date)
.map((c) => ({
id: c.id,
date: c.due_date.split("T")[0],
title: c.title,
time: "",
color: priorityColor[c.priority] ?? "blue",
description: `${(projects as Project[])[i].name}${c.priority}`,
})),
);
});
} finally {
loading = false;
}
});
async function addEvent(ev: SubmitEvent) {
ev.preventDefault();
if (!newEvent.title.trim() || !newEvent.date) return;
saving = true;
error = "";
try {
const created = await teamsApi.createEvent(teamId, {
title: newEvent.title,
date: newEvent.date,
time: newEvent.time,
color: newEvent.color,
description: newEvent.description,
});
teamEvents = [...teamEvents, created];
isModalOpen = false;
newEvent = {
title: "",
date: "",
time: "",
color: "blue",
description: "",
};
} catch {
error = "Failed to create event.";
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Team Calendar — FPMB</title>
<meta
name="description"
content="View and manage team events and card due dates on this team's calendar in FPMB."
/>
</svelte:head>
<div class="flex flex-col -m-6 p-6">
<header
class="flex flex-col md:flex-row md:items-center justify-between mb-6 pb-6 border-b border-neutral-700 shrink-0 gap-4"
>
<div class="flex items-center space-x-4">
<a
href="/team/{teamId}"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
</a>
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
Team Calendar
</h1>
<p class="text-sm text-neutral-400 mt-1">
Team events and task due dates
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<button
onclick={() => (isModalOpen = true)}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center gap-2"
>
<Icon icon="lucide:plus" class="w-4 h-4" />
Add Event
</button>
</div>
</header>
{#if loading}
<div class="flex-1 flex items-center justify-center text-neutral-400">
Loading events...
</div>
{:else}
<Calendar events={calendarEvents} />
{/if}
</div>
{#if isModalOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0" onclick={() => (isModalOpen = false)}></div>
<div
class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full max-w-md"
>
<div
class="flex items-center justify-between p-4 border-b border-neutral-700"
>
<h2 class="text-lg font-semibold text-white">Add Event</h2>
<button
onclick={() => (isModalOpen = false)}
class="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-700"
>
<Icon icon="lucide:x" class="w-5 h-5" />
</button>
</div>
<form onsubmit={addEvent} class="p-6 space-y-4">
{#if error}
<p class="text-sm text-red-400">{error}</p>
{/if}
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Title</label
>
<input
type="text"
bind:value={newEvent.title}
required
placeholder="Event title"
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Description</label
>
<textarea
bind:value={newEvent.description}
rows="2"
placeholder="Optional description"
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm resize-none"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Date</label
>
<input
type="date"
bind:value={newEvent.date}
required
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Time</label
>
<input
type="text"
bind:value={newEvent.time}
placeholder="e.g. 10:00 AM"
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Color</label
>
<select
bind:value={newEvent.color}
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
>
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="red">Red</option>
<option value="yellow">Yellow</option>
<option value="purple">Purple</option>
</select>
</div>
<div class="flex justify-end pt-2 gap-3">
<button
type="button"
onclick={() => (isModalOpen = false)}
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-colors disabled:opacity-50"
>
{saving ? "Saving..." : "Save Event"}
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,529 @@
<script lang="ts">
import { page } from "$app/stores";
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { teams as teamsApi } from "$lib/api";
import { getAccessToken } from "$lib/api/client";
import { authStore } from "$lib/stores/auth.svelte";
import type { Team, ChatMessage } from "$lib/types/api";
let teamId = $derived($page.params.id ?? "");
let team = $state<Team | null>(null);
let messages = $state<ChatMessage[]>([]);
let newMessage = $state("");
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let sending = $state(false);
let ws: WebSocket | null = null;
let wsConnected = $state(false);
let destroyed = false;
let onlineUsers = $state<{ user_id: string; name: string }[]>([]);
let typingUsers = $state<
Record<string, { name: string; timeout: ReturnType<typeof setTimeout> }>
>({});
let messagesContainer: HTMLDivElement;
let shouldAutoScroll = $state(true);
let inputEl: HTMLTextAreaElement;
let typingNames = $derived(Object.values(typingUsers).map((t) => t.name));
let myId = $derived(authStore.user?.id ?? "");
function connectWS() {
const token = getAccessToken();
if (!token || destroyed) return;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.hostname;
const port =
window.location.port ||
(window.location.protocol === "https:" ? "443" : "80");
const userName = authStore.user?.name ?? "Anonymous";
const url = `${proto}//${host}:${port}/ws/team/${teamId}/chat?token=${encodeURIComponent(token)}&name=${encodeURIComponent(userName)}`;
ws = new WebSocket(url);
ws.onopen = () => {
wsConnected = true;
};
ws.onclose = () => {
wsConnected = false;
if (!destroyed) {
setTimeout(() => {
if (!destroyed && (!ws || ws.readyState === WebSocket.CLOSED))
connectWS();
}, 3000);
}
};
ws.onerror = () => {
wsConnected = false;
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleWSMessage(msg);
} catch {}
};
}
function handleWSMessage(msg: Record<string, unknown>) {
const type = msg.type as string;
if (type === "presence" && Array.isArray(msg.users)) {
onlineUsers = (msg.users as { user_id: string; name: string }[]).filter(
(u) => u.user_id !== myId,
);
}
if (type === "message" && msg.message) {
const chatMsg = msg.message as ChatMessage;
messages = [...messages, chatMsg];
if (shouldAutoScroll) {
requestAnimationFrame(scrollToBottom);
}
}
if (
type === "typing" &&
typeof msg.user_id === "string" &&
msg.user_id !== myId
) {
const uid = msg.user_id as string;
const name = (msg.name as string) || "?";
if (typingUsers[uid]) clearTimeout(typingUsers[uid].timeout);
const timeout = setTimeout(() => {
const copy = { ...typingUsers };
delete copy[uid];
typingUsers = copy;
}, 3000);
typingUsers = { ...typingUsers, [uid]: { name, timeout } };
}
}
function scrollToBottom() {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
function handleScroll() {
if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 60;
if (scrollTop < 80 && hasMore && !loadingMore) {
loadMore();
}
}
async function loadMore() {
if (messages.length === 0 || !hasMore) return;
loadingMore = true;
const oldHeight = messagesContainer?.scrollHeight ?? 0;
try {
const older = await teamsApi.listChatMessages(teamId, messages[0].id);
if (older.length < 50) hasMore = false;
if (older.length > 0) {
messages = [...older, ...messages];
requestAnimationFrame(() => {
if (messagesContainer) {
messagesContainer.scrollTop =
messagesContainer.scrollHeight - oldHeight;
}
});
}
} catch {}
loadingMore = false;
}
let typeSendTimer: ReturnType<typeof setTimeout> | null = null;
function sendTyping() {
if (typeSendTimer) return;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "typing" }));
}
typeSendTimer = setTimeout(() => {
typeSendTimer = null;
}, 2000);
}
function sendMessage() {
const content = newMessage.trim();
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
sending = true;
ws.send(JSON.stringify({ type: "message", content }));
newMessage = "";
sending = false;
shouldAutoScroll = true;
requestAnimationFrame(() => inputEl?.focus());
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
} else {
sendTyping();
}
}
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = d.toDateString() === yesterday.toDateString();
const time = d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
if (isToday) return time;
if (isYesterday) return `Yesterday ${time}`;
return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${time}`;
}
const AVATAR_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#ec4899",
];
function getAvatarColor(name: string) {
let hash = 0;
for (let i = 0; i < name.length; i++)
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
function shouldShowHeader(idx: number) {
if (idx === 0) return true;
const prev = messages[idx - 1];
const curr = messages[idx];
if (prev.user_id !== curr.user_id) return true;
const diff =
new Date(curr.created_at).getTime() - new Date(prev.created_at).getTime();
return diff > 5 * 60 * 1000;
}
function shouldShowDate(idx: number) {
if (idx === 0) return true;
const prev = new Date(messages[idx - 1].created_at).toDateString();
const curr = new Date(messages[idx].created_at).toDateString();
return prev !== curr;
}
function formatDateSeparator(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
return d.toLocaleDateString([], {
weekday: "long",
month: "long",
day: "numeric",
});
}
onMount(() => {
const init = async () => {
try {
const [teamData, chatData] = await Promise.all([
teamsApi.get(teamId),
teamsApi.listChatMessages(teamId),
]);
team = teamData;
messages = chatData;
if (chatData.length < 50) hasMore = false;
} catch {}
loading = false;
requestAnimationFrame(scrollToBottom);
connectWS();
};
init();
return () => {
destroyed = true;
if (typeSendTimer) clearTimeout(typeSendTimer);
for (const t of Object.values(typingUsers)) clearTimeout(t.timeout);
if (ws) {
ws.onclose = null;
ws.close();
}
};
});
</script>
<svelte:head>
<title>{team ? `Chat — ${team.name}` : "Team Chat"} — FPMB</title>
<meta name="description" content="Real-time team chat in FPMB." />
</svelte:head>
<div class="h-[calc(100vh-5.5rem)] flex flex-col -m-6 lg:-m-8">
<!-- Header -->
<div
class="shrink-0 bg-neutral-800/60 backdrop-blur-sm border-b border-neutral-700/60 px-5 py-3"
>
<div class="flex items-center justify-between max-w-4xl mx-auto">
<div class="flex items-center gap-3">
<a
href="/team/{teamId}"
class="p-1.5 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
title="Back to team"
>
<Icon icon="lucide:arrow-left" class="w-4 h-4" />
</a>
<div
class="w-9 h-9 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center"
>
<Icon
icon="lucide:message-circle"
class="w-4.5 h-4.5 text-blue-400"
/>
</div>
<div>
<h1 class="text-sm font-semibold text-white leading-tight">
{team?.name ?? "Team"} Chat
</h1>
<p class="text-[11px] text-neutral-500 leading-tight mt-0.5">
{#if onlineUsers.length > 0}
{onlineUsers.length + 1} members online
{:else}
Just you
{/if}
</p>
</div>
</div>
<div class="flex items-center gap-3">
{#if onlineUsers.length > 0}
<div class="flex -space-x-2">
{#each onlineUsers.slice(0, 5) as user}
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold text-white border-2 border-neutral-800 ring-1 ring-neutral-700 transition-transform hover:scale-110 hover:z-10"
style="background-color: {getAvatarColor(user.name)}"
title={user.name}
>
{user.name.charAt(0).toUpperCase()}
</div>
{/each}
{#if onlineUsers.length > 5}
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-medium text-neutral-300 bg-neutral-700 border-2 border-neutral-800"
>
+{onlineUsers.length - 5}
</div>
{/if}
</div>
{/if}
<div
class="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700"
>
<div
class="w-1.5 h-1.5 rounded-full {wsConnected
? 'bg-emerald-400 shadow-[0_0_6px_theme(colors.emerald.400)]'
: 'bg-red-400 shadow-[0_0_6px_theme(colors.red.400)]'}"
></div>
<span
class="text-[10px] font-medium {wsConnected
? 'text-emerald-400'
: 'text-red-400'}"
>
{wsConnected ? "Live" : "Offline"}
</span>
</div>
</div>
</div>
</div>
<!-- Messages area -->
<div
bind:this={messagesContainer}
onscroll={handleScroll}
class="flex-1 overflow-y-auto"
>
<div class="max-w-4xl mx-auto px-5 py-4">
{#if loading}
<div class="flex items-center justify-center h-64">
<div class="flex flex-col items-center gap-3">
<Icon
icon="lucide:loader-2"
class="w-6 h-6 text-blue-400 animate-spin"
/>
<span class="text-xs text-neutral-500">Loading messages…</span>
</div>
</div>
{:else if messages.length === 0}
<div class="flex flex-col items-center justify-center h-64 text-center">
<div
class="w-20 h-20 rounded-2xl bg-gradient-to-br from-blue-600/20 to-purple-600/20 border border-blue-500/20 flex items-center justify-center mb-5"
>
<Icon icon="lucide:message-circle" class="w-9 h-9 text-blue-400" />
</div>
<h3 class="text-lg font-semibold text-white mb-1.5">
Start the conversation
</h3>
<p class="text-sm text-neutral-500 max-w-xs leading-relaxed">
Messages are visible to all team members. Say hello!
</p>
</div>
{:else}
{#if loadingMore}
<div class="flex justify-center py-4">
<Icon
icon="lucide:loader-2"
class="w-4 h-4 text-neutral-500 animate-spin"
/>
</div>
{/if}
{#if !hasMore}
<div class="flex items-center gap-3 py-4 mb-2">
<div class="flex-1 h-px bg-neutral-800"></div>
<span
class="text-[10px] font-medium text-neutral-600 uppercase tracking-wider"
>Beginning of conversation</span
>
<div class="flex-1 h-px bg-neutral-800"></div>
</div>
{/if}
{#each messages as msg, idx}
{@const isMe = msg.user_id === myId}
{@const showHeader = shouldShowHeader(idx)}
{@const showDate = shouldShowDate(idx)}
{#if showDate}
<div class="flex items-center gap-3 py-3 my-2">
<div class="flex-1 h-px bg-neutral-800"></div>
<span
class="text-[10px] font-medium text-neutral-500 uppercase tracking-wider"
>{formatDateSeparator(msg.created_at)}</span
>
<div class="flex-1 h-px bg-neutral-800"></div>
</div>
{/if}
<div
class="group relative {showHeader
? 'mt-5'
: 'mt-0.5'} rounded-lg hover:bg-white/[0.02] px-2 py-0.5 -mx-2 transition-colors"
>
{#if showHeader}
<div class="flex items-center gap-2.5 mb-1">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-md"
style="background-color: {getAvatarColor(msg.user_name)}"
>
{msg.user_name.charAt(0).toUpperCase()}
</div>
<span
class="text-[13px] font-semibold {isMe
? 'text-blue-300'
: 'text-neutral-100'}">{isMe ? "You" : msg.user_name}</span
>
<span class="text-[11px] text-neutral-600"
>{formatTime(msg.created_at)}</span
>
</div>
{/if}
<div class={showHeader ? "pl-[42px]" : "pl-[42px]"}>
<p
class="text-[13.5px] text-neutral-300 leading-[1.55] whitespace-pre-wrap wrap-break-word"
>
{msg.content}
</p>
</div>
<span
class="absolute right-2 top-1 text-[10px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity"
>{formatTime(msg.created_at)}</span
>
</div>
{/each}
{/if}
</div>
</div>
<!-- Typing indicator -->
{#if typingNames.length > 0}
<div class="shrink-0 px-5">
<div
class="max-w-4xl mx-auto py-1.5 flex items-center gap-2 text-xs text-neutral-500"
>
<span class="flex gap-[3px]">
<span
class="w-[5px] h-[5px] bg-blue-400/60 rounded-full animate-bounce"
style="animation-delay: 0ms"
></span>
<span
class="w-[5px] h-[5px] bg-blue-400/60 rounded-full animate-bounce"
style="animation-delay: 150ms"
></span>
<span
class="w-[5px] h-[5px] bg-blue-400/60 rounded-full animate-bounce"
style="animation-delay: 300ms"
></span>
</span>
{#if typingNames.length === 1}
<span
><strong class="text-neutral-400">{typingNames[0]}</strong> is typing…</span
>
{:else if typingNames.length === 2}
<span
><strong class="text-neutral-400">{typingNames[0]}</strong> and
<strong class="text-neutral-400">{typingNames[1]}</strong> are typing…</span
>
{:else}
<span
><strong class="text-neutral-400"
>{typingNames.length} people</strong
> are typing…</span
>
{/if}
</div>
</div>
{/if}
<!-- Input -->
<div
class="shrink-0 border-t border-neutral-700/60 bg-neutral-800/40 backdrop-blur-sm px-5 py-3"
>
<div class="max-w-4xl mx-auto">
<div class="flex items-end gap-2.5">
<div class="flex-1 relative">
<textarea
bind:this={inputEl}
bind:value={newMessage}
onkeydown={handleKeydown}
placeholder={wsConnected ? "Type a message…" : "Connecting…"}
rows="1"
class="w-full resize-none bg-neutral-800 border border-neutral-600/80 rounded-xl px-4 py-2.5 text-sm text-white placeholder-neutral-500 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all max-h-32"
disabled={!wsConnected}
></textarea>
</div>
<button
onclick={sendMessage}
disabled={!newMessage.trim() || !wsConnected || sending}
class="p-2.5 rounded-xl transition-all duration-150 shrink-0 {newMessage.trim() &&
wsConnected
? 'bg-blue-600 text-white hover:bg-blue-500 shadow-md shadow-blue-600/20 hover:shadow-blue-500/30 active:scale-95'
: 'bg-neutral-800 text-neutral-600 border border-neutral-700 cursor-not-allowed'}"
title="Send (Enter)"
>
<Icon icon="lucide:send" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { page } from "$app/stores";
import Icon from "@iconify/svelte";
import Markdown from "$lib/components/Markdown/Markdown.svelte";
import { onMount } from "svelte";
import { teams as teamsApi, docs as docsApi } from "$lib/api";
import type { Doc, FileItem } from "$lib/types/api";
let teamId = $derived($page.params.id ?? "");
let docs = $state<Doc[]>([]);
let activeDoc = $state<Doc | null>(null);
let teamFiles = $state<FileItem[]>([]);
let isEditing = $state(false);
let editTitle = $state("");
let editContent = $state("");
let saving = $state(false);
onMount(async () => {
[docs, teamFiles] = await Promise.all([
teamsApi.listDocs(teamId),
teamsApi.listFiles(teamId).catch(() => [] as FileItem[]),
]);
if (docs.length > 0) {
activeDoc = await docsApi.get(docs[0].id);
}
});
async function selectDoc(doc: Doc) {
activeDoc = await docsApi.get(doc.id);
isEditing = false;
}
function startEdit() {
if (!activeDoc) return;
editTitle = activeDoc.title;
editContent = activeDoc.content;
isEditing = true;
}
async function saveDoc() {
if (!activeDoc) return;
saving = true;
try {
const updated = await docsApi.update(activeDoc.id, {
title: editTitle,
content: editContent,
});
docs = docs.map((d) => (d.id === updated.id ? updated : d));
activeDoc = updated;
isEditing = false;
} finally {
saving = false;
}
}
async function createNewDoc() {
const created = await teamsApi.createDoc(
teamId,
"Untitled Document",
"# Untitled Document\n\nStart typing here...",
);
docs = [created, ...docs];
activeDoc = created;
editTitle = created.title;
editContent = created.content;
isEditing = true;
}
</script>
<svelte:head>
<title>Team Docs — FPMB</title>
<meta
name="description"
content="Browse and edit your team's Markdown knowledge base documents in FPMB."
/>
</svelte:head>
<div class="flex flex-col -m-6 p-6 overflow-hidden h-full">
<div
class="flex flex-1 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800 shadow-sm h-full"
>
<!-- Sidebar List -->
<div
class="w-80 border-r border-neutral-700 flex flex-col shrink-0 bg-neutral-850"
>
<div
class="p-4 border-b border-neutral-700 flex items-center justify-between"
>
<h2 class="text-lg font-semibold text-white">Team Docs</h2>
<button
onclick={createNewDoc}
class="p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
title="New Document"
>
<Icon icon="lucide:file-plus" class="w-5 h-5" />
</button>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<ul class="divide-y divide-neutral-700">
{#each docs as doc (doc.id)}
<li>
<button
onclick={() => selectDoc(doc)}
class="w-full text-left px-4 py-3 hover:bg-neutral-750 transition-colors flex items-start gap-3 {activeDoc?.id ===
doc.id
? 'bg-neutral-750 border-l-2 border-blue-500'
: 'border-l-2 border-transparent'}"
>
<Icon
icon="lucide:file-text"
class="w-5 h-5 text-neutral-400 mt-0.5 shrink-0"
/>
<div class="flex-1 min-w-0">
<h3
class="text-sm font-medium text-white truncate {activeDoc?.id ===
doc.id
? 'text-blue-400'
: ''}"
>
{doc.title}
</h3>
<p class="text-xs text-neutral-500 mt-1">
{new Date(doc.updated_at).toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
})}
</p>
</div>
</button>
</li>
{/each}
</ul>
</div>
</div>
<!-- Main Content Area -->
<div class="flex-1 flex flex-col min-w-0 bg-neutral-900 overflow-hidden">
{#if activeDoc}
<div
class="flex items-center justify-between px-8 py-4 border-b border-neutral-700 bg-neutral-850 shrink-0"
>
<div class="flex-1 min-w-0 mr-4">
{#if isEditing}
<input
type="text"
bind:value={editTitle}
class="block w-full px-3 py-1.5 border border-neutral-600 rounded-md bg-neutral-800 text-white text-lg font-semibold focus:ring-blue-500 focus:border-blue-500"
/>
{:else}
<h1 class="text-xl font-bold text-white truncate">
{activeDoc.title}
</h1>
<p class="text-xs text-neutral-500 mt-0.5">
Last updated {new Date(activeDoc.updated_at).toLocaleDateString(
"en-US",
{ month: "2-digit", day: "2-digit", year: "numeric" },
)}
</p>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
{#if isEditing}
<button
onclick={saveDoc}
disabled={saving}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded-md text-sm font-medium transition-colors disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
{:else}
<button
onclick={startEdit}
class="bg-neutral-700 hover:bg-neutral-600 text-white px-4 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-2"
>
<Icon icon="lucide:edit-2" class="w-4 h-4" />
Edit
</button>
{/if}
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-8">
<div class="max-w-5xl mx-auto">
{#if isEditing}
<textarea
bind:value={editContent}
class="w-full h-[600px] bg-neutral-800 border border-neutral-700 text-neutral-300 rounded-lg p-4 font-mono text-sm focus:ring-blue-500 focus:border-blue-500 resize-y"
placeholder="Write your markdown here..."
></textarea>
{:else}
<div
class="bg-neutral-800 rounded-lg border border-neutral-700 p-8 shadow-sm min-h-full"
>
<Markdown content={activeDoc.content} files={teamFiles} />
</div>
{/if}
</div>
</div>
{:else}
<div
class="flex-1 flex flex-col items-center justify-center text-neutral-500"
>
<Icon icon="lucide:file-text" class="w-16 h-16 mb-4 opacity-50" />
<p class="text-lg">Select a document or create a new one</p>
</div>
{/if}
</div>
</div>
</div>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #525252;
border-radius: 20px;
}
</style>

View File

@@ -0,0 +1,387 @@
<script lang="ts">
import { page } from "$app/stores";
import { teams as teamsApi, files as filesApi } from "$lib/api";
import type { FileItem } from "$lib/types/api";
import FileViewer from "$lib/components/FileViewer/FileViewer.svelte";
let teamId = $derived($page.params.id ?? "");
let folderStack = $state<{ id: string; name: string }[]>([]);
let currentParentId = $derived(
folderStack.length > 0 ? folderStack[folderStack.length - 1].id : "",
);
let fileList = $state<FileItem[]>([]);
let loading = $state(true);
let folderName = $state("");
let showFolderInput = $state(false);
let savingFolder = $state(false);
async function loadFiles(parentId: string) {
loading = true;
try {
fileList = await teamsApi.listFiles(teamId, parentId);
} catch {
fileList = [];
} finally {
loading = false;
}
}
$effect(() => {
loadFiles(currentParentId);
});
function formatSize(bytes: number): string {
if (!bytes) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
}
async function createFolder(e: SubmitEvent) {
e.preventDefault();
if (!folderName.trim()) return;
savingFolder = true;
try {
const created = await teamsApi.createFolder(
teamId,
folderName.trim(),
currentParentId,
);
fileList = [created, ...fileList];
folderName = "";
showFolderInput = false;
} catch {
} finally {
savingFolder = false;
}
}
async function handleUpload(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const created = await teamsApi.uploadFile(teamId, file, currentParentId);
fileList = [...fileList, created];
} catch {}
input.value = "";
}
async function deleteFile(id: string) {
if (!confirm("Delete this item?")) return;
try {
await filesApi.delete(id);
fileList = fileList.filter((f) => f.id !== id);
} catch {}
}
function openFolder(folder: FileItem) {
folderStack = [...folderStack, { id: folder.id, name: folder.name }];
}
function navigateToBreadcrumb(index: number) {
if (index === -1) {
folderStack = [];
} else {
folderStack = folderStack.slice(0, index + 1);
}
}
function getIcon(type: string) {
if (type === "folder") {
return `<svg class="w-6 h-6 text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path></svg>`;
}
return `<svg class="w-6 h-6 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>`;
}
let viewingFile = $state<FileItem | null>(null);
let fileInput: HTMLInputElement;
</script>
<svelte:head>
<title>Team Files — FPMB</title>
<meta
name="description"
content="Browse, upload, and organise your team's shared files and folders in FPMB."
/>
</svelte:head>
<div class="h-full flex flex-col -m-6 p-6 overflow-hidden">
<header
class="flex flex-col md:flex-row md:items-center justify-between mb-6 pb-6 border-b border-neutral-700 shrink-0 gap-4"
>
<div class="flex items-center space-x-4">
<a
href="/team/{teamId}"
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
></path></svg
>
</a>
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
Team Files
</h1>
<div
class="text-sm text-neutral-400 flex items-center space-x-2 mt-1 flex-wrap gap-y-1"
>
<button
onclick={() => navigateToBreadcrumb(-1)}
class="hover:text-blue-400 transition-colors">Root</button
>
{#each folderStack as crumb, i}
<span>/</span>
<button
onclick={() => navigateToBreadcrumb(i)}
class="hover:text-blue-400 transition-colors">{crumb.name}</button
>
{/each}
<span>/</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<button
onclick={() => (showFolderInput = !showFolderInput)}
class="bg-neutral-800 hover:bg-neutral-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors text-sm flex items-center"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
></path></svg
>
New Folder
</button>
<input
bind:this={fileInput}
type="file"
class="hidden"
onchange={handleUpload}
/>
<button
onclick={() => fileInput.click()}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
></path></svg
>
Upload
</button>
</div>
</header>
{#if showFolderInput}
<form onsubmit={createFolder} class="mb-4 flex gap-2">
<input
type="text"
bind:value={folderName}
placeholder="Folder name"
required
autofocus
class="flex-1 px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<button
type="submit"
disabled={savingFolder}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50"
>
{savingFolder ? "Creating..." : "Create"}
</button>
<button
type="button"
onclick={() => (showFolderInput = false)}
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
>
Cancel
</button>
</form>
{/if}
<div
class="flex-1 overflow-auto bg-neutral-800 rounded-lg shadow-sm border border-neutral-700"
>
{#if loading}
<div class="p-12 text-center text-neutral-400">Loading files...</div>
{:else}
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-neutral-850 border-b border-neutral-700">
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Name</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-32 hidden sm:table-cell"
>Size</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-40 hidden md:table-cell"
>Last Modified</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider text-right w-24"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{#each fileList as file (file.id)}
<tr
class="hover:bg-neutral-750 transition-colors group cursor-pointer"
ondblclick={() =>
file.type === "folder"
? openFolder(file)
: (viewingFile = file)}
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="shrink-0 flex items-center justify-center">
{@html getIcon(file.type)}
</div>
<div class="ml-4">
{#if file.type === "folder"}
<button
onclick={() => openFolder(file)}
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors text-left"
>{file.name}</button
>
{:else}
<div
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors"
>
{file.name}
</div>
{/if}
<div class="text-xs text-neutral-500 sm:hidden mt-1">
{formatSize(file.size_bytes)}{formatDate(
file.updated_at,
)}
</div>
</div>
</div>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden sm:table-cell"
>
{formatSize(file.size_bytes)}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden md:table-cell"
>
{formatDate(file.updated_at)}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
>
<div
class="flex items-center justify-end space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if file.type === "file" && file.storage_url}
<button
onclick={() => filesApi.download(file.id, file.name)}
class="text-neutral-400 hover:text-white p-1 rounded"
title="Download"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
></path></svg
>
</button>
{/if}
<button
onclick={() => deleteFile(file.id)}
class="text-neutral-400 hover:text-red-400 p-1 rounded"
title="Delete"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path></svg
>
</button>
</div>
</td>
</tr>
{/each}
{#if fileList.length === 0}
<tr>
<td colspan="4" class="px-6 py-12 text-center text-neutral-400">
<div class="flex flex-col items-center">
<svg
class="w-12 h-12 text-neutral-600 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
></path></svg
>
<p>This folder is empty.</p>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
{/if}
</div>
</div>
<FileViewer bind:file={viewingFile} downloadUrl={filesApi.downloadUrl} />

View File

@@ -0,0 +1,364 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { teams as teamsApi } from "$lib/api";
import type { Team, TeamMember } from "$lib/types/api";
import { RoleFlag } from "$lib/types/roles";
let teamId = $derived($page.params.id ?? "");
let team = $state<Team | null>(null);
let members = $state<TeamMember[]>([]);
let teamName = $state("");
let inviteEmail = $state("");
let inviteRole = $state(RoleFlag.Editor);
let saving = $state(false);
let error = $state("");
let avatarFile = $state<File | null>(null);
let bannerFile = $state<File | null>(null);
let avatarUploading = $state(false);
let bannerUploading = $state(false);
let avatarPreview = $state("");
let bannerPreview = $state("");
onMount(async () => {
const [teamData, memberData] = await Promise.all([
teamsApi.get(teamId),
teamsApi.listMembers(teamId),
]);
team = teamData;
members = memberData;
teamName = teamData.name;
avatarPreview = teamData.avatar_url ?? "";
bannerPreview = teamData.banner_url ?? "";
});
async function saveGeneral(e: Event) {
e.preventDefault();
saving = true;
try {
const updated = await teamsApi.update(teamId, { name: teamName });
team = updated;
} finally {
saving = false;
}
}
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
avatarFile = file;
avatarPreview = URL.createObjectURL(file);
}
function handleBannerChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
bannerFile = file;
bannerPreview = URL.createObjectURL(file);
}
async function uploadAvatar() {
if (!avatarFile) return;
avatarUploading = true;
try {
const updated = await teamsApi.uploadAvatar(teamId, avatarFile);
team = updated;
avatarFile = null;
} finally {
avatarUploading = false;
}
}
async function uploadBanner() {
if (!bannerFile) return;
bannerUploading = true;
try {
const updated = await teamsApi.uploadBanner(teamId, bannerFile);
team = updated;
bannerFile = null;
} finally {
bannerUploading = false;
}
}
async function handleInvite(e: Event) {
e.preventDefault();
error = "";
if (!inviteEmail) return;
try {
const res = await teamsApi.invite(teamId, inviteEmail, inviteRole);
members = [...members, res.member];
inviteEmail = "";
} catch (err: unknown) {
error = err instanceof Error ? err.message : "Failed to invite";
}
}
async function removeMember(userId: string) {
await teamsApi.removeMember(teamId, userId);
members = members.filter((m) => m.user_id !== userId);
}
</script>
<svelte:head>
<title
>{teamName ? `${teamName} Settings FPMB` : "Team Settings — FPMB"}</title
>
<meta
name="description"
content="Manage team name, avatar, banner, and member roles in FPMB."
/>
</svelte:head>
<div class="max-w-5xl mx-auto space-y-10">
<div class="flex justify-between items-end">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight mb-2">
Team Settings
</h1>
<p class="text-neutral-400">Manage your team members and roles.</p>
</div>
</div>
<!-- General Section -->
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div class="p-6 border-b border-neutral-700">
<h2 class="text-xl font-semibold text-white mb-1">General</h2>
<p class="text-sm text-neutral-400">
Update your team's name and description.
</p>
</div>
<form onsubmit={saveGeneral} class="p-6 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-1"
>Team Name</label
>
<input
type="text"
bind:value={teamName}
class="block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div class="flex justify-end pt-2">
<button
type="submit"
disabled={saving}
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</section>
<!-- Avatar Section -->
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div class="p-6 border-b border-neutral-700">
<h2 class="text-xl font-semibold text-white mb-1">Team Avatar</h2>
<p class="text-sm text-neutral-400">
Upload a square image to represent your team.
</p>
</div>
<div class="p-6 flex items-start gap-6">
<div
class="w-20 h-20 rounded-xl border border-neutral-600 overflow-hidden shrink-0 bg-neutral-700 flex items-center justify-center"
>
{#if avatarPreview}
<img
src={avatarPreview}
alt="Team avatar"
class="w-full h-full object-cover"
/>
{:else}
<span class="text-3xl font-bold text-white"
>{team?.name.charAt(0) ?? ""}</span
>
{/if}
</div>
<div class="flex flex-col gap-3">
<label class="block text-sm font-medium text-neutral-300">
Image file <span class="text-neutral-500 font-normal"
>(jpg, png, gif, webp)</span
>
</label>
<input
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp"
onchange={handleAvatarChange}
class="block text-sm text-neutral-400 file:mr-3 file:py-1.5 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-neutral-700 file:text-white hover:file:bg-neutral-600 cursor-pointer"
/>
<button
type="button"
onclick={uploadAvatar}
disabled={!avatarFile || avatarUploading}
class="self-start bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-md text-sm disabled:opacity-50 transition-colors"
>
{avatarUploading ? "Uploading..." : "Upload Avatar"}
</button>
</div>
</div>
</section>
<!-- Banner Section -->
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div class="p-6 border-b border-neutral-700">
<h2 class="text-xl font-semibold text-white mb-1">Team Banner</h2>
<p class="text-sm text-neutral-400">
Upload a wide image shown at the top of your team page.
</p>
</div>
<div class="p-6 flex flex-col gap-4">
<div
class="w-full h-28 rounded-lg border border-neutral-600 overflow-hidden bg-neutral-700 flex items-center justify-center"
>
{#if bannerPreview}
<img
src={bannerPreview}
alt="Team banner"
class="w-full h-full object-cover"
/>
{:else}
<span class="text-sm text-neutral-500">No banner set</span>
{/if}
</div>
<div class="flex flex-col gap-3">
<label class="block text-sm font-medium text-neutral-300">
Image file <span class="text-neutral-500 font-normal"
>(jpg, png, gif, webp)</span
>
</label>
<input
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp"
onchange={handleBannerChange}
class="block text-sm text-neutral-400 file:mr-3 file:py-1.5 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-neutral-700 file:text-white hover:file:bg-neutral-600 cursor-pointer"
/>
<button
type="button"
onclick={uploadBanner}
disabled={!bannerFile || bannerUploading}
class="self-start bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-md text-sm disabled:opacity-50 transition-colors"
>
{bannerUploading ? "Uploading..." : "Upload Banner"}
</button>
</div>
</div>
</section>
<!-- Members Section -->
<section
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
>
<div
class="p-6 border-b border-neutral-700 flex flex-col md:flex-row md:items-center justify-between gap-4"
>
<div>
<h2 class="text-xl font-semibold text-white mb-1">Members</h2>
<p class="text-sm text-neutral-400">
Invite new members and manage roles.
</p>
</div>
<form onsubmit={handleInvite} class="flex space-x-2 w-full md:w-auto">
<input
type="email"
placeholder="name@example.com"
bind:value={inviteEmail}
required
class="block w-full min-w-48 px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
<select
bind:value={inviteRole}
class="px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white text-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value={RoleFlag.Viewer}>Viewer</option>
<option value={RoleFlag.Editor}>Editor</option>
<option value={RoleFlag.Admin}>Admin</option>
</select>
<button
type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm whitespace-nowrap"
>
Invite
</button>
</form>
</div>
{#if error}
<div class="px-6 py-3 text-red-400 text-sm border-b border-neutral-700">
{error}
</div>
{/if}
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-neutral-850 border-b border-neutral-700">
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Member</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>Role</th
>
<th
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider text-right"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{#each members as member (member.user_id)}
<tr class="hover:bg-neutral-750 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div
class="h-8 w-8 rounded-full bg-blue-900 text-blue-300 flex items-center justify-center font-bold text-xs shadow-inner"
>
{member.name.charAt(0).toUpperCase()}
</div>
<div class="ml-4">
<div class="text-sm font-medium text-white">
{member.name}
</div>
<div class="text-xs text-neutral-500">{member.email}</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-2.5 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-neutral-700 text-neutral-300 border border-neutral-600"
>
{member.role_name}
</span>
</td>
<td
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
>
{#if member.role_flags < RoleFlag.Owner}
<button
onclick={() => removeMember(member.user_id)}
class="text-red-500 hover:text-red-400">Remove</button
>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import '../layout.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>FPMB</title>
</svelte:head>
<div class="h-screen w-screen overflow-hidden bg-neutral-900 text-neutral-50 flex items-center justify-center">
{@render children()}
</div>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { authStore } from "$lib/stores/auth.svelte";
let email = $state("");
let password = $state("");
let isLoading = $state(false);
let error = $state("");
async function handleSubmit(event: Event) {
event.preventDefault();
isLoading = true;
error = "";
try {
await authStore.login(email, password);
goto("/");
} catch (e: unknown) {
error = e instanceof Error ? e.message : "Login failed";
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Sign In — FPMB</title>
<meta
name="description"
content="Sign in to your FPMB account to manage your projects, teams, and tasks."
/>
</svelte:head>
<div
class="w-full max-w-md p-8 bg-neutral-800 rounded-lg shadow-xl border border-neutral-700"
>
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-white tracking-tight">FPMB</h1>
<p class="text-neutral-400 mt-2">Sign in to your account</p>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
{#if error}
<div
class="rounded-md bg-red-900/50 border border-red-700 p-3 text-sm text-red-300"
>
{error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-neutral-300"
>Email address</label
>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
bind:value={email}
class="appearance-none block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-neutral-300"
>Password</label
>
<div class="mt-1">
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
bind:value={password}
class="appearance-none block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-neutral-600 rounded bg-neutral-700"
/>
<label for="remember-me" class="ml-2 block text-sm text-neutral-300"
>Remember me</label
>
</div>
<div class="text-sm">
<a href="#" class="font-medium text-blue-500 hover:text-blue-400"
>Forgot your password?</a
>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{#if isLoading}
Signing in...
{:else}
Sign in
{/if}
</button>
</div>
</form>
<div class="mt-6 text-center text-sm">
<span class="text-neutral-400">Don't have an account?</span>
<a
href="/register"
class="font-medium text-blue-500 hover:text-blue-400 ml-1">Sign up</a
>
</div>
</div>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { authStore } from "$lib/stores/auth.svelte";
let name = $state("");
let email = $state("");
let password = $state("");
let confirmPassword = $state("");
let isLoading = $state(false);
let error = $state("");
async function handleSubmit(event: Event) {
event.preventDefault();
if (password !== confirmPassword) {
error = "Passwords do not match";
return;
}
isLoading = true;
error = "";
try {
await authStore.register(name, email, password);
goto("/");
} catch (e: unknown) {
error = e instanceof Error ? e.message : "Registration failed";
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Create Account — FPMB</title>
<meta
name="description"
content="Create a new FPMB account to start managing projects and collaborating with your team."
/>
</svelte:head>
<div
class="w-full max-w-md p-8 bg-neutral-800 rounded-lg shadow-xl border border-neutral-700"
>
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-white tracking-tight">FPMB</h1>
<p class="text-neutral-400 mt-2">Create a new account</p>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
{#if error}
<div
class="rounded-md bg-red-900/50 border border-red-700 p-3 text-sm text-red-300"
>
{error}
</div>
{/if}
<div>
<label for="name" class="block text-sm font-medium text-neutral-300"
>Full name</label
>
<div class="mt-1">
<input
id="name"
name="name"
type="text"
autocomplete="name"
required
bind:value={name}
class="appearance-none block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium text-neutral-300"
>Email address</label
>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
bind:value={email}
class="appearance-none block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-neutral-300"
>Password</label
>
<div class="mt-1">
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
class="appearance-none block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-neutral-300"
>Confirm Password</label
>
<div class="mt-1">
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autocomplete="new-password"
required
bind:value={confirmPassword}
class="appearance-none block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-offset-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{#if isLoading}
Creating account...
{:else}
Create account
{/if}
</button>
</div>
</form>
<div class="mt-6 text-center text-sm">
<span class="text-neutral-400">Already have an account?</span>
<a href="/login" class="font-medium text-blue-500 hover:text-blue-400 ml-1"
>Sign in</a
>
</div>
</div>

16
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,16 @@
<script lang="ts">
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>FPMB — Free Project Management Boards</title>
<meta
name="description"
content="FPMB is a self-hosted, open-source project management platform with Kanban boards, team collaboration, whiteboards, docs, and more."
/>
</svelte:head>
{@render children()}

44
src/routes/layout.css Normal file
View File

@@ -0,0 +1,44 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@theme {
--font-sans: 'JetBrains Mono', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
@layer base {
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
}
body {
@apply bg-neutral-900 text-neutral-50 antialiased h-screen w-screen overflow-hidden;
font-family: 'JetBrains Mono', sans-serif;
}
/* Force dark mode background and text colors globally */
* {
@apply border-neutral-700;
}
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

Binary file not shown.

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

16
svelte.config.js Normal file
View File

@@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html', // Needed for SPA-like dynamic routing
precompress: false,
strict: true
})
}
};
export default config;

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

5
vite.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });