ALL 0.1.0 Code
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
data
|
||||
server/bin
|
||||
.git
|
||||
*.md
|
||||
.env
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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/
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
471
BUILD.md
Normal file
471
BUILD.md
Normal 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
23
Dockerfile
Normal 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
197
README.md
Normal 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
37
docker-compose.yml
Normal 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
5
example.env
Normal 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
32
package.json
Normal 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
BIN
server/bin/openboard
Executable file
Binary file not shown.
246
server/cmd/api/main.go
Normal file
246
server/cmd/api/main.go
Normal 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
36
server/go.mod
Normal 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
85
server/go.sum
Normal 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=
|
||||
50
server/internal/database/database.go
Normal file
50
server/internal/database/database.go
Normal 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)
|
||||
}
|
||||
165
server/internal/handlers/apikeys.go
Normal file
165
server/internal/handlers/apikeys.go
Normal 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"})
|
||||
}
|
||||
199
server/internal/handlers/auth.go
Normal file
199
server/internal/handlers/auth.go
Normal 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"})
|
||||
}
|
||||
484
server/internal/handlers/board.go
Normal file
484
server/internal/handlers/board.go
Normal 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"})
|
||||
}
|
||||
238
server/internal/handlers/chat.go
Normal file
238
server/internal/handlers/chat.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
221
server/internal/handlers/docs.go
Normal file
221
server/internal/handlers/docs.go
Normal 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"})
|
||||
}
|
||||
285
server/internal/handlers/events.go
Normal file
285
server/internal/handlers/events.go
Normal 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"})
|
||||
}
|
||||
590
server/internal/handlers/files.go
Normal file
590
server/internal/handlers/files.go
Normal 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"})
|
||||
}
|
||||
111
server/internal/handlers/notifications.go
Normal file
111
server/internal/handlers/notifications.go
Normal 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, ¬ifications)
|
||||
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"})
|
||||
}
|
||||
633
server/internal/handlers/projects.go
Normal file
633
server/internal/handlers/projects.go
Normal 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"})
|
||||
}
|
||||
602
server/internal/handlers/teams.go
Normal file
602
server/internal/handlers/teams.go
Normal 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")
|
||||
}
|
||||
230
server/internal/handlers/users.go
Normal file
230
server/internal/handlers/users.go
Normal 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"})
|
||||
}
|
||||
220
server/internal/handlers/webhooks.go
Normal file
220
server/internal/handlers/webhooks.go
Normal 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"})
|
||||
}
|
||||
96
server/internal/handlers/whiteboard.go
Normal file
96
server/internal/handlers/whiteboard.go
Normal 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})
|
||||
}
|
||||
164
server/internal/handlers/ws.go
Normal file
164
server/internal/handlers/ws.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
server/internal/middleware/auth.go
Normal file
51
server/internal/middleware/auth.go
Normal 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()
|
||||
}
|
||||
}
|
||||
184
server/internal/models/models.go
Normal file
184
server/internal/models/models.go
Normal 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
13
src/app.d.ts
vendored
Normal 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
11
src/app.html
Normal 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
99
src/lib/api/client.ts
Normal 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
371
src/lib/api/index.ts
Normal 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' })
|
||||
};
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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 |
284
src/lib/components/Calendar/Calendar.svelte
Normal file
284
src/lib/components/Calendar/Calendar.svelte
Normal 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}
|
||||
243
src/lib/components/FileViewer/FileViewer.svelte
Normal file
243
src/lib/components/FileViewer/FileViewer.svelte
Normal 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}
|
||||
43
src/lib/components/Markdown/Markdown.svelte
Normal file
43
src/lib/components/Markdown/Markdown.svelte
Normal 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>
|
||||
29
src/lib/components/Modal/Modal.svelte
Normal file
29
src/lib/components/Modal/Modal.svelte
Normal 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
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
77
src/lib/stores/auth.svelte.ts
Normal file
77
src/lib/stores/auth.svelte.ts
Normal 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
193
src/lib/types/api.ts
Normal 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
10
src/lib/types/roles.ts
Normal 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
11
src/lib/utils/fileRefs.ts
Normal 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}\``;
|
||||
});
|
||||
}
|
||||
252
src/routes/(app)/+layout.svelte
Normal file
252
src/routes/(app)/+layout.svelte
Normal 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>·</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>
|
||||
237
src/routes/(app)/+page.svelte
Normal file
237
src/routes/(app)/+page.svelte
Normal 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}
|
||||
633
src/routes/(app)/api-docs/+page.svelte
Normal file
633
src/routes/(app)/api-docs/+page.svelte
Normal 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 <token></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>
|
||||
1459
src/routes/(app)/board/[id]/+page.svelte
Normal file
1459
src/routes/(app)/board/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
256
src/routes/(app)/calendar/+page.svelte
Normal file
256
src/routes/(app)/calendar/+page.svelte
Normal 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>
|
||||
622
src/routes/(app)/docs/+page.svelte
Normal file
622
src/routes/(app)/docs/+page.svelte
Normal 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
|
||||
/> {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>
|
||||
382
src/routes/(app)/files/+page.svelte
Normal file
382
src/routes/(app)/files/+page.svelte
Normal 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} />
|
||||
175
src/routes/(app)/notifications/+page.svelte
Normal file
175
src/routes/(app)/notifications/+page.svelte
Normal 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>
|
||||
306
src/routes/(app)/projects/+page.svelte
Normal file
306
src/routes/(app)/projects/+page.svelte
Normal 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}
|
||||
215
src/routes/(app)/projects/[id]/calendar/+page.svelte
Normal file
215
src/routes/(app)/projects/[id]/calendar/+page.svelte
Normal 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}
|
||||
391
src/routes/(app)/projects/[id]/files/+page.svelte
Normal file
391
src/routes/(app)/projects/[id]/files/+page.svelte
Normal 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} />
|
||||
234
src/routes/(app)/projects/[id]/settings/+page.svelte
Normal file
234
src/routes/(app)/projects/[id]/settings/+page.svelte
Normal 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>
|
||||
372
src/routes/(app)/projects/[id]/webhooks/+page.svelte
Normal file
372
src/routes/(app)/projects/[id]/webhooks/+page.svelte
Normal 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}
|
||||
711
src/routes/(app)/settings/user/+page.svelte
Normal file
711
src/routes/(app)/settings/user/+page.svelte
Normal 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>
|
||||
541
src/routes/(app)/team/[id]/+page.svelte
Normal file
541
src/routes/(app)/team/[id]/+page.svelte
Normal 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}
|
||||
270
src/routes/(app)/team/[id]/calendar/+page.svelte
Normal file
270
src/routes/(app)/team/[id]/calendar/+page.svelte
Normal 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}
|
||||
529
src/routes/(app)/team/[id]/chat/+page.svelte
Normal file
529
src/routes/(app)/team/[id]/chat/+page.svelte
Normal 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>
|
||||
226
src/routes/(app)/team/[id]/docs/+page.svelte
Normal file
226
src/routes/(app)/team/[id]/docs/+page.svelte
Normal 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>
|
||||
387
src/routes/(app)/team/[id]/files/+page.svelte
Normal file
387
src/routes/(app)/team/[id]/files/+page.svelte
Normal 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} />
|
||||
364
src/routes/(app)/team/[id]/settings/+page.svelte
Normal file
364
src/routes/(app)/team/[id]/settings/+page.svelte
Normal 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>
|
||||
1103
src/routes/(app)/whiteboard/[id]/+page.svelte
Normal file
1103
src/routes/(app)/whiteboard/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
15
src/routes/(auth)/+layout.svelte
Normal file
15
src/routes/(auth)/+layout.svelte
Normal 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>
|
||||
125
src/routes/(auth)/login/+page.svelte
Normal file
125
src/routes/(auth)/login/+page.svelte
Normal 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>
|
||||
145
src/routes/(auth)/register/+page.svelte
Normal file
145
src/routes/(auth)/register/+page.svelte
Normal 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
16
src/routes/+layout.svelte
Normal 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
44
src/routes/layout.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
BIN
static/fonts/JetBrainsMono-SemiBold.ttf
Normal file
BIN
static/fonts/JetBrainsMono-SemiBold.ttf
Normal file
Binary file not shown.
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
16
svelte.config.js
Normal file
16
svelte.config.js
Normal 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
20
tsconfig.json
Normal 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
5
vite.config.ts
Normal 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()] });
|
||||
Reference in New Issue
Block a user