Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Calixte
2b4d056ab8 feat(export): download project photos as a grouped zip
Add POST /api/export {projectIds}: one folder per project (named after it),
photos named <lastname-firstname>.<ext>, with a generated initials SVG for
Members without a Slack photo. Dedupe image fetches, bounded concurrency, fflate
zip. Factor data.ts so routes + export share one fixtures/live gating path.
Frontend: Export-photos button downloads the zip.
2026-06-26 16:29:05 +01:00
Julien Calixte
30b96ecf64 feat(ui): multi-select projects, grouped grid, refresh, slack warnings
ProjectPicker becomes a searchable multi-select (chips + checklist). App renders
one ProjectGroup per selected project (a Person on several appears in each), with
per-group member/photo counts. Surface Slack status (degraded/unconfigured) and a
Refresh-avatars button that re-sweeps the directory and reloads groups.
2026-06-26 16:25:08 +01:00
Julien Calixte
09238df7e3 style: apply oxfmt to backend + docs 2026-06-26 16:25:08 +01:00
17 changed files with 531 additions and 135 deletions

View File

@@ -12,27 +12,27 @@ Strength weights used below: **9** strong, **3** medium, **1** weak, blank none.
## 1. Goals — the WHATs
| ID | Goal | Weight | Source |
|----|------|:------:|--------|
| G1 | Pick a Project and immediately see who's on it, as faces | 10 | user request |
| G2 | Each Member shows the correct name + Role | 8 | user request |
| G3 | Find a specific Project fast, even among hundreds | 6 | [Q3] |
| G4 | Stay useful when Slack is down or a Member has no Slack | 6 | [Q6] |
| G5 | Keep the roster private to the team | 7 | [Q7] |
| G6 | Select several Projects, see their teams grouped, and export the photos | 8 | user request |
| ID | Goal | Weight | Source |
| --- | ----------------------------------------------------------------------- | :----: | ------------ |
| G1 | Pick a Project and immediately see who's on it, as faces | 10 | user request |
| G2 | Each Member shows the correct name + Role | 8 | user request |
| G3 | Find a specific Project fast, even among hundreds | 6 | [Q3] |
| G4 | Stay useful when Slack is down or a Member has no Slack | 6 | [Q6] |
| G5 | Keep the roster private to the team | 7 | [Q7] |
| G6 | Select several Projects, see their teams grouped, and export the photos | 8 | user request |
## 2. Functions — the HOWs
| ID | Function | Dir | Target (now) | Target (future) |
|----|----------|:---:|--------------|-----------------|
| F1 | Resolve a Project's Members from Napta (real + active only) | | correct set | — |
| F2 | Render a Project's grid end-to-end | ↓ | ≤ 2 s p95 (warm cache) | ≤ 1 s |
| F3 | Match Members → Slack Avatars | ↑ | in-memory vs cached directory | — |
| F4 | Authenticate to Napta (Auth0 M2M) | → | cached JWT, refresh on 401/expiry | — |
| F5 | Keep the Slack directory cache fresh | → | 30-day TTL + manual Refresh | — |
| F6 | Filter/search the Project picker (multi-select) | ↓ | instant on ≤ few hundred (client-side) | server-side if thousands |
| F7 | Gate access to the whole site | → | Basic Auth at nginx | SSO |
| F8 | Build the Export zip (folder per Project Group) | ↓ | ≤ ~15 s for a few hundred photos | stream + progress |
| ID | Function | Dir | Target (now) | Target (future) |
| --- | ----------------------------------------------------------- | :-: | -------------------------------------- | ------------------------ |
| F1 | Resolve a Project's Members from Napta (real + active only) | | correct set | — |
| F2 | Render a Project's grid end-to-end | | ≤ 2 s p95 (warm cache) | ≤ 1 s |
| F3 | Match Members → Slack Avatars | ↑ | in-memory vs cached directory | — |
| F4 | Authenticate to Napta (Auth0 M2M) | | cached JWT, refresh on 401/expiry | — |
| F5 | Keep the Slack directory cache fresh | → | 30-day TTL + manual Refresh | — |
| F6 | Filter/search the Project picker (multi-select) | | instant on ≤ few hundred (client-side) | server-side if thousands |
| F7 | Gate access to the whole site | | Basic Auth at nginx | SSO |
| F8 | Build the Export zip (folder per Project Group) | | ≤ ~15 s for a few hundred photos | stream + progress |
## 3. Cascade — Goals → Functions → How → Components
@@ -57,38 +57,38 @@ Strength weights used below: **9** strong, **3** medium, **1** weak, blank none.
- **Component**: `src/components/ProjectPicker.vue`
- **G4** Useful when Slack is down / no match _(W6)_
- **F7→degrade**: if the directory sweep fails, return Members anyway with `slackMatched:false` + a `slackError` flag; UI shows initials + dismissible warning
- **Component**: `backend/src/slack.ts`, `MemberGrid.vue`
- **Component**: `backend/src/slack.ts`, `MemberGrid.vue`
- **G5** Private to the team _(W7)_
- **F7** Basic Auth at the edge
- **How**: nginx `auth_basic` over the whole site incl. `/api`; htpasswd generated at container start from `BASIC_AUTH_USER`/`BASIC_AUTH_PASSWORD`; no-op when unset (local dev)
- **Component**: `nginx.conf`, web image entrypoint
- **G6** Multi-select + grouped view + photo Export _(W8)_
- **F6** Multi-select searchable picker; chosen Projects render as Project Groups (a Person on several appears in each)
- **Component**: `src/components/ProjectPicker.vue`, `src/App.vue`, `ProjectGroup.vue`
- **Component**: `src/components/ProjectPicker.vue`, `src/App.vue`, `ProjectGroup.vue`
- **F8** Export zip
- **How**: `POST /api/export {projectIds[]}` → per Project Group fetch Members, fetch each Avatar's bytes (dedup within the export, bounded concurrency), write `<project>/<lastname-firstname>.<ext>`; unmatched Members get a generated **initials SVG**; stream the zip (`fflate`)
- **Component**: `backend/src/export.ts`, `backend/src/avatar.ts` (initials SVG), `backend/src/index.ts`
## 7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it |
|------|----------|--------|------------|---------------|
| 1 | F2 grid render | ≤ 2 s p95 (warm) | backend request logs | parallelize Napta calls; cache Positions table; batch `user` fetch by id |
| 2 | F5 directory sweep | ≤ ~10 s for full workspace | sweep duration log | paginate + serve stale snapshot while refreshing in background |
| 3 | F1 Member resolution | correct set, not slow path | spot-check vs Napta UI | add `staffed_days > 0` filter if "assigned but never staffed" noise appears |
| 4 | F8 Export build | ≤ ~15 s, a few hundred photos | export duration log | bounded-concurrency avatar fetch; dedup repeated avatars; cap selection size with a warning |
| Rank | Function | Target | Watched on | If we miss it |
| ---- | -------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------- |
| 1 | F2 grid render | ≤ 2 s p95 (warm) | backend request logs | parallelize Napta calls; cache Positions table; batch `user` fetch by id |
| 2 | F5 directory sweep | ≤ ~10 s for full workspace | sweep duration log | paginate + serve stale snapshot while refreshing in background |
| 3 | F1 Member resolution | correct set, not slow path | spot-check vs Napta UI | add `staffed_days > 0` filter if "assigned but never staffed" noise appears |
| 4 | F8 Export build | ≤ ~15 s, a few hundred photos | export duration log | bounded-concurrency avatar fetch; dedup repeated avatars; cap selection size with a warning |
## 8. Tradeoffs — Got / Paid / ADR
| ID | Tradeoff | Got | Paid | ADR |
|----|----------|-----|------|-----|
| T1 | `users.list` whole-workspace sweep over per-email lookup | few Slack calls, rate-limit-safe, in-memory matching | fetch entire directory; up to 30-day staleness (mitigated by Refresh) | [ADR-0001](./docs/adr/0001-slack-directory-cache.md) |
| T2 | Napta Auth0 M2M cached JWT over a static token | matches Napta's real auth; survives token expiry | token-exchange code + refresh-on-401 | — |
| T3 | HTTP Basic Auth over SSO | strangers kept out with ~zero build | one shared credential; no per-user identity/audit; easily swapped later | — |
| T4 | Email as the only Napta↔Slack join key | one reliable key, no fuzzy matching | Members whose Slack email differs go unmatched (shown with initials) | — |
| T5 | Display-only grid for v1 | ships the core ask fastest | no click-to-Slack / copy-email yet | — |
| T6 | Initials Avatars exported as SVG, not rasterized PNG | zero native deps, clean Alpine image, scalable | mixed extensions in folders; SVG unsuitable where only raster embeds | — |
| T7 | Server-side zip (`fflate`), built in memory | no Slack-CDN CORS, has avatar bytes; simple | whole zip held in memory; large Selections need streaming (F8 future) | — |
| ID | Tradeoff | Got | Paid | ADR |
| --- | -------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
| T1 | `users.list` whole-workspace sweep over per-email lookup | few Slack calls, rate-limit-safe, in-memory matching | fetch entire directory; up to 30-day staleness (mitigated by Refresh) | [ADR-0001](./docs/adr/0001-slack-directory-cache.md) |
| T2 | Napta Auth0 M2M cached JWT over a static token | matches Napta's real auth; survives token expiry | token-exchange code + refresh-on-401 | — |
| T3 | HTTP Basic Auth over SSO | strangers kept out with ~zero build | one shared credential; no per-user identity/audit; easily swapped later | — |
| T4 | Email as the only Napta↔Slack join key | one reliable key, no fuzzy matching | Members whose Slack email differs go unmatched (shown with initials) | — |
| T5 | Display-only grid for v1 | ships the core ask fastest | no click-to-Slack / copy-email yet | — |
| T6 | Initials Avatars exported as SVG, not rasterized PNG | zero native deps, clean Alpine image, scalable | mixed extensions in folders; SVG unsuitable where only raster embeds | — |
| T7 | Server-side zip (`fflate`), built in memory | no Slack-CDN CORS, has avatar bytes; simple | whole zip held in memory; large Selections need streaming (F8 future) | — |
### Tensions being watched (unresolved by design)

View File

@@ -40,10 +40,10 @@ cd backend && pnpm typecheck
Set on the Coolify app (or in `backend/.env` locally — see `backend/.env.example`):
| Var | Purpose |
|---|---|
| `NAPTA_API_TOKEN` | Napta API token (lists projects + their people) |
| `NAPTA_BASE_URL` | Napta API base (default `https://app.napta.io/api/v1`) |
| Var | Purpose |
| ----------------- | --------------------------------------------------------- |
| `NAPTA_API_TOKEN` | Napta API token (lists projects + their people) |
| `NAPTA_BASE_URL` | Napta API base (default `https://app.napta.io/api/v1`) |
| `SLACK_BOT_TOKEN` | Slack bot token, scopes `users:read` + `users:read.email` |
Without **both** tokens the API serves demo fixtures and the UI shows a

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@hono/node-server": "^2.0.6",
"fflate": "^0.8.3",
"hono": "^4.12.27"
},
"devDependencies": {

View File

@@ -11,6 +11,9 @@ importers:
'@hono/node-server':
specifier: ^2.0.6
version: 2.0.6(hono@4.12.27)
fflate:
specifier: ^0.8.3
version: 0.8.3
hono:
specifier: ^4.12.27
version: 4.12.27
@@ -33,6 +36,9 @@ packages:
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
hono@4.12.27:
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
engines: {node: '>=16.9.0'}
@@ -55,6 +61,8 @@ snapshots:
dependencies:
undici-types: 8.3.0
fflate@0.8.3: {}
hono@4.12.27: {}
typescript@6.0.3: {}

30
backend/src/avatar.ts Normal file
View File

@@ -0,0 +1,30 @@
// Deterministic initials Avatar as an SVG string — used in the Export for
// Members with no Slack photo, mirroring the in-app initials fallback.
const COLORS = ["#4A154B", "#1264A3", "#2EB67D", "#E01E5A", "#ECB22E", "#611F69", "#36C5F0"]
function hashCode(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
return Math.abs(h)
}
function escapeXml(s: string): string {
const map: Record<string, string> = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
"'": "&apos;",
'"': "&quot;",
}
return s.replace(/[<>&'"]/g, (c) => map[c] ?? c)
}
export function initialsAvatarSvg(firstName: string, lastName: string): string {
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || "?"
const color = COLORS[hashCode(`${firstName} ${lastName}`) % COLORS.length] ?? COLORS[0]
return `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="${color}"/>
<text x="256" y="256" dy="0.08em" fill="#ffffff" font-family="Inter, system-ui, sans-serif" font-size="220" font-weight="600" text-anchor="middle" dominant-baseline="central">${escapeXml(initials)}</text>
</svg>`
}

23
backend/src/data.ts Normal file
View File

@@ -0,0 +1,23 @@
import { hasNaptaCredentials } from "./config.ts"
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
import { type EnrichResult, enrichWithSlackAvatars } from "./slack.ts"
import type { DataSource, Project } from "./types.ts"
// Single source of truth for "fixtures vs live", shared by the API routes and
// the Export so the gating logic isn't duplicated.
export async function getProjects(): Promise<{ source: DataSource; projects: Project[] }> {
if (!hasNaptaCredentials) return { source: "fixture", projects: fixtureProjects }
return { source: "napta", projects: await fetchNaptaProjects() }
}
export async function getProjectMembers(
projectId: string,
): Promise<{ source: DataSource } & EnrichResult> {
if (!hasNaptaCredentials) {
return { source: "fixture", members: fixtureMembers(projectId), slack: "unconfigured" }
}
const enriched = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(projectId))
return { source: "napta", ...enriched }
}

View File

@@ -29,9 +29,9 @@ export interface DirectoryEntry {
}
export function getDirectoryRefreshedAt(): number | null {
const row = db
.prepare("SELECT refreshed_at FROM slack_directory_meta WHERE id = 1")
.get() as { refreshed_at: number } | undefined
const row = db.prepare("SELECT refreshed_at FROM slack_directory_meta WHERE id = 1").get() as
| { refreshed_at: number }
| undefined
return row ? row.refreshed_at : null
}
@@ -56,9 +56,11 @@ export function replaceDirectory(entries: DirectoryEntry[]): void {
}
export function getDirectoryMap(): Map<string, DirectoryEntry> {
const rows = db
.prepare("SELECT email, image_url, slack_id FROM slack_directory")
.all() as { email: string; image_url: string | null; slack_id: string }[]
const rows = db.prepare("SELECT email, image_url, slack_id FROM slack_directory").all() as {
email: string
image_url: string | null
slack_id: string
}[]
const map = new Map<string, DirectoryEntry>()
for (const r of rows) {
map.set(r.email, { email: r.email, imageUrl: r.image_url, slackId: r.slack_id })

98
backend/src/export.ts Normal file
View File

@@ -0,0 +1,98 @@
import { strToU8, zipSync } from "fflate"
import { getProjectMembers, getProjects } from "./data.ts"
import { initialsAvatarSvg } from "./avatar.ts"
import type { Member } from "./types.ts"
const CONCURRENCY = 8
// Keep folder/file names filesystem-safe; collapse whitespace.
function sanitize(s: string): string {
const cleaned = s
.replace(/[^\p{L}\p{N} ._-]/gu, "")
.replace(/\s+/g, " ")
.trim()
return cleaned || "untitled"
}
function fileBase(m: Member): string {
const base = sanitize(`${m.lastName} ${m.firstName}`).toLowerCase().replace(/\s+/g, "-")
return base || `member-${m.id}`
}
function extFromUrl(url: string): string {
const path = url.split("?")[0] ?? ""
const m = path.match(/\.(jpe?g|png|gif|webp)$/i)
return m ? m[1]!.toLowerCase().replace("jpeg", "jpg") : "jpg"
}
async function fetchImage(url: string): Promise<Uint8Array> {
const res = await fetch(url)
if (!res.ok) throw new Error(`image ${res.status}`)
return new Uint8Array(await res.arrayBuffer())
}
async function runPool<T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
let i = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const item = items[i++]!
await fn(item)
}
})
await Promise.all(workers)
}
// Build a zip: one folder per Project (folder = project name), each holding its
// Members' photos named `<lastname-firstname>.<ext>`. Members with no Slack photo
// get a generated initials SVG. Image fetches are deduped across the whole export.
export async function buildExportZip(projectIds: string[]): Promise<Uint8Array> {
const { projects } = await getProjects()
const nameById = new Map(projects.map((p) => [p.id, p.name]))
const files: Record<string, Uint8Array> = {}
const folderCounts = new Map<string, number>()
const imageCache = new Map<string, Uint8Array>()
for (const pid of projectIds) {
const baseFolder = sanitize(nameById.get(pid) ?? pid)
const seen = folderCounts.get(baseFolder) ?? 0
folderCounts.set(baseFolder, seen + 1)
const folder = seen === 0 ? baseFolder : `${baseFolder} (${seen + 1})`
const { members } = await getProjectMembers(pid)
// Assign unique file bases within this folder before fetching.
const usedNames = new Set<string>()
const planned = members.map((m) => {
let candidate = fileBase(m)
let n = 1
while (usedNames.has(candidate)) candidate = `${fileBase(m)}-${++n}`
usedNames.add(candidate)
return { member: m, base: candidate }
})
await runPool(planned, CONCURRENCY, async ({ member, base }) => {
if (member.imageUrl) {
let bytes = imageCache.get(member.imageUrl)
if (!bytes) {
try {
bytes = await fetchImage(member.imageUrl)
imageCache.set(member.imageUrl, bytes)
} catch {
bytes = undefined
}
}
if (bytes) {
files[`${folder}/${base}.${extFromUrl(member.imageUrl)}`] = bytes
return
}
}
files[`${folder}/${base}.svg`] = strToU8(initialsAvatarSvg(member.firstName, member.lastName))
})
}
return zipSync(files, { level: 6 })
}

View File

@@ -10,12 +10,7 @@ export const fixtureProjects: Project[] = [
{ id: "demo-zephyr", name: "Zephyr Mobile App", clientName: "Globex" },
]
function member(
id: string,
firstName: string,
lastName: string,
role: string,
): Member {
function member(id: string, firstName: string, lastName: string, role: string): Member {
return {
id,
firstName,

View File

@@ -2,9 +2,9 @@ import { serve } from "@hono/node-server"
import { Hono } from "hono"
import { logger } from "hono/logger"
import { config, hasNaptaCredentials, hasSlackCredentials } from "./config.ts"
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
import { enrichWithSlackAvatars, refreshDirectory } from "./slack.ts"
import { getProjectMembers, getProjects } from "./data.ts"
import { refreshDirectory } from "./slack.ts"
import { buildExportZip } from "./export.ts"
import "./db.ts" // initialise the SQLite schema at boot
function errorMessage(err: unknown): string {
@@ -17,30 +17,19 @@ app.use("*", logger())
app.get("/api/health", (c) => c.json({ status: "ok" }))
app.get("/api/projects", async (c) => {
if (!hasNaptaCredentials) {
return c.json({ source: "fixture", projects: fixtureProjects })
}
try {
const projects = await fetchNaptaProjects()
return c.json({ source: "napta", projects })
return c.json(await getProjects())
} catch (err) {
console.error("fetchNaptaProjects failed:", err)
console.error("getProjects failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
})
app.get("/api/projects/:id/members", async (c) => {
const id = c.req.param("id")
if (!hasNaptaCredentials) {
return c.json({ source: "fixture", slack: "unconfigured", members: fixtureMembers(id) })
}
try {
const { members, slack } = await enrichWithSlackAvatars(
await fetchNaptaProjectMembers(id),
)
return c.json({ source: "napta", slack, members })
return c.json(await getProjectMembers(c.req.param("id")))
} catch (err) {
console.error("fetchNaptaProjectMembers failed:", err)
console.error("getProjectMembers failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
})
@@ -49,15 +38,35 @@ app.get("/api/projects/:id/members", async (c) => {
app.post("/api/slack/refresh", async (c) => {
if (!hasSlackCredentials) return c.json({ error: "Slack is not configured" }, 400)
try {
const refreshed = await refreshDirectory()
return c.json({ refreshed })
return c.json({ refreshed: await refreshDirectory() })
} catch (err) {
console.error("slack refresh failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
})
// Export the selected Projects' photos as a zip (one folder per Project).
app.post("/api/export", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { projectIds?: unknown }
const ids = Array.isArray(body.projectIds)
? body.projectIds.filter((x): x is string => typeof x === "string")
: []
if (ids.length === 0) return c.json({ error: "projectIds required" }, 400)
try {
const zip = await buildExportZip(ids)
return new Response(zip, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="photofetch-${ids.length}-projects.zip"`,
},
})
} catch (err) {
console.error("export failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
})
console.log(
`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`,
`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials}, slack: ${hasSlackCredentials})`,
)
serve({ fetch: app.fetch, port: config.port })

View File

@@ -65,11 +65,7 @@ interface QueryOpts {
pageSize?: number
}
async function naptaGetPage(
path: string,
opts: QueryOpts,
page: number,
): Promise<JsonApiResponse> {
async function naptaGetPage(path: string, opts: QueryOpts, page: number): Promise<JsonApiResponse> {
const token = await getAccessToken()
const url = new URL(`${config.naptaBaseUrl}${path}`)
// flask-rest-jsonapi: ?filter=[{"name","op","val"}], page[size], page[number], include.
@@ -198,7 +194,6 @@ export async function fetchNaptaProjectMembers(projectId: string): Promise<Membe
}
return [...byUserId.values()].sort(
(a, b) =>
a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName),
(a, b) => a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName),
)
}

View File

@@ -52,8 +52,7 @@ export async function refreshDirectory(): Promise<number> {
if (u.deleted || u.is_bot || u.id === "USLACKBOT") continue
const email = u.profile?.email
if (!email) continue
const imageUrl =
u.profile?.image_512 || u.profile?.image_192 || u.profile?.image_72 || null
const imageUrl = u.profile?.image_512 || u.profile?.image_192 || u.profile?.image_72 || null
entries.push({ email, imageUrl, slackId: u.id })
}
cursor = page.response_metadata?.next_cursor || undefined

View File

@@ -1,50 +1,108 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue"
import type { DataSource, Member, Project } from "@/types"
import { fetchMembers, fetchProjects } from "@/api"
import { computed, onMounted, ref, watch } from "vue"
import type { DataSource, Member, Project, SlackStatus } from "@/types"
import { exportPhotos, fetchMembers, fetchProjects, refreshSlackDirectory } from "@/api"
import ProjectPicker from "@/components/ProjectPicker.vue"
import MemberGrid from "@/components/MemberGrid.vue"
import ProjectGroup from "@/components/ProjectGroup.vue"
interface GroupState {
project: Project
members: Member[]
loading: boolean
error: string | null
}
const projects = ref<Project[]>([])
const selectedProjectId = ref<string>("")
const members = ref<Member[]>([])
const selectedIds = ref<string[]>([])
const groups = ref<GroupState[]>([])
const source = ref<DataSource>("napta")
const slackStatus = ref<SlackStatus | null>(null)
const projectsError = ref<string | null>(null)
const membersError = ref<string | null>(null)
const loadingMembers = ref(false)
const refreshing = ref(false)
const exporting = ref(false)
const exportError = ref<string | null>(null)
function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
const projectById = computed(() => {
const m = new Map<string, Project>()
for (const p of projects.value) m.set(p.id, p)
return m
})
onMounted(async () => {
try {
const res = await fetchProjects()
projects.value = res.projects
source.value = res.source
if (res.projects.length > 0) {
selectedProjectId.value = res.projects[0].id
}
} catch (err) {
projectsError.value = err instanceof Error ? err.message : String(err)
projectsError.value = errMsg(err)
}
})
watch(selectedProjectId, async (id) => {
if (!id) {
members.value = []
return
}
loadingMembers.value = true
membersError.value = null
// Reassign the array element by id so reactivity fires reliably.
function setGroup(projectId: string, patch: Partial<GroupState>) {
const i = groups.value.findIndex((g) => g.project.id === projectId)
if (i >= 0) groups.value[i] = { ...groups.value[i], ...patch }
}
async function loadGroup(projectId: string) {
setGroup(projectId, { loading: true, error: null })
try {
const res = await fetchMembers(id)
members.value = res.members
const res = await fetchMembers(projectId)
setGroup(projectId, { members: res.members, loading: false })
slackStatus.value = res.slack
source.value = res.source
} catch (err) {
membersError.value = err instanceof Error ? err.message : String(err)
members.value = []
} finally {
loadingMembers.value = false
setGroup(projectId, { members: [], error: errMsg(err), loading: false })
}
}
// Reconcile the open Project Groups with the current Selection.
watch(selectedIds, (ids) => {
groups.value = groups.value.filter((g) => ids.includes(g.project.id))
for (const id of ids) {
if (groups.value.some((g) => g.project.id === id)) continue
const project = projectById.value.get(id)
if (!project) continue
groups.value.push({ project, members: [], loading: true, error: null })
loadGroup(id)
}
groups.value.sort((a, b) => ids.indexOf(a.project.id) - ids.indexOf(b.project.id))
})
async function refreshAvatars() {
refreshing.value = true
try {
await refreshSlackDirectory()
await Promise.all(groups.value.map((g) => loadGroup(g.project.id)))
} catch (err) {
slackStatus.value = "degraded"
console.error(err)
} finally {
refreshing.value = false
}
}
async function downloadExport() {
exporting.value = true
exportError.value = null
try {
const blob = await exportPhotos(selectedIds.value)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `photofetch-${selectedIds.value.length}-projects.zip`
a.click()
URL.revokeObjectURL(url)
} catch (err) {
exportError.value = errMsg(err)
} finally {
exporting.value = false
}
}
</script>
<template>
@@ -54,33 +112,79 @@ watch(selectedProjectId, async (id) => {
<img src="/favicon.svg" alt="" class="size-7" />
<span class="text-xl font-semibold">photofetch</span>
</div>
<div class="flex-none">
<div class="flex flex-none items-center gap-2">
<button
v-if="groups.length > 0"
type="button"
class="btn btn-ghost btn-sm"
:disabled="refreshing"
title="Re-sweep the Slack directory"
@click="refreshAvatars"
>
<span v-if="refreshing" class="loading loading-spinner loading-xs"></span>
Refresh avatars
</button>
<button
v-if="groups.length > 0"
type="button"
class="btn btn-primary btn-sm"
:disabled="exporting"
title="Download a zip of photos, one folder per project"
@click="downloadExport"
>
<span v-if="exporting" class="loading loading-spinner loading-xs"></span>
Export photos
</button>
<ProjectPicker
v-model="selectedProjectId"
v-model="selectedIds"
:projects="projects"
:disabled="projects.length === 0"
/>
</div>
</header>
<main class="mx-auto max-w-6xl p-4 sm:p-6">
<div v-if="source === 'fixture'" role="alert" class="alert alert-warning mb-4">
<main class="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
<div v-if="source === 'fixture'" role="alert" class="alert alert-warning">
<span>
Showing demo data. Set <code>NAPTA_API_TOKEN</code> and <code>SLACK_BOT_TOKEN</code> on
the server to load your real projects.
Showing demo data. Set <code>NAPTA_CLIENT_ID</code> /
<code>NAPTA_CLIENT_SECRET</code> (and <code>SLACK_BOT_TOKEN</code>) on the server to load
real projects.
</span>
</div>
<div v-if="slackStatus === 'degraded'" role="alert" class="alert alert-warning">
<span>Couldn't reach Slack — showing names without up-to-date photos. Try Refresh.</span>
</div>
<div
v-else-if="slackStatus === 'unconfigured' && source === 'napta'"
role="alert"
class="alert alert-info"
>
<span>
Slack isn't configured, so Members show initials. Set
<code>SLACK_BOT_TOKEN</code> to load photos.
</span>
</div>
<div v-if="exportError" role="alert" class="alert alert-error">
<span>Export failed: {{ exportError }}</span>
</div>
<div v-if="projectsError" role="alert" class="alert alert-error">
<span>Could not load projects: {{ projectsError }}</span>
</div>
<MemberGrid
v-else
:members="members"
:loading="loadingMembers"
:error="membersError"
:project-selected="!!selectedProjectId"
<div v-else-if="groups.length === 0" class="py-20 text-center opacity-60">
<p>Pick one or more projects to see their teams.</p>
</div>
<ProjectGroup
v-for="g in groups"
:key="g.project.id"
:project="g.project"
:members="g.members"
:loading="g.loading"
:error="g.error"
/>
</main>
</div>

View File

@@ -16,3 +16,25 @@ export function fetchProjects(): Promise<ProjectsResponse> {
export function fetchMembers(projectId: string): Promise<MembersResponse> {
return getJson<MembersResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`)
}
export async function refreshSlackDirectory(): Promise<{ refreshed: number }> {
const res = await fetch("/api/slack/refresh", { method: "POST" })
if (!res.ok) {
const body = await res.text()
throw new Error(`${res.status} ${res.statusText}${body ? `: ${body}` : ""}`)
}
return (await res.json()) as { refreshed: number }
}
export async function exportPhotos(projectIds: string[]): Promise<Blob> {
const res = await fetch("/api/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectIds }),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`${res.status} ${res.statusText}${body ? `: ${body}` : ""}`)
}
return res.blob()
}

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from "vue"
import type { Member, Project } from "@/types"
import MemberGrid from "@/components/MemberGrid.vue"
const props = defineProps<{
project: Project
members: Member[]
loading: boolean
error: string | null
}>()
const matchedCount = computed(() => props.members.filter((m) => m.slackMatched).length)
</script>
<template>
<section>
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 class="text-lg font-semibold">
{{ project.name }}
<span v-if="project.clientName" class="font-normal opacity-60">
· {{ project.clientName }}
</span>
</h2>
<span v-if="!loading && !error" class="shrink-0 text-sm opacity-60">
{{ members.length }} {{ members.length === 1 ? "person" : "people" }}
<template v-if="members.length"> · {{ matchedCount }} with photos</template>
</span>
</div>
<MemberGrid :members="members" :loading="loading" :error="error" :project-selected="true" />
</section>
</template>

View File

@@ -1,28 +1,104 @@
<script setup lang="ts">
import { computed, ref } from "vue"
import type { Project } from "@/types"
defineProps<{
const props = defineProps<{
projects: Project[]
modelValue: string
modelValue: string[]
disabled?: boolean
}>()
defineEmits<{
"update:modelValue": [value: string]
const emit = defineEmits<{
"update:modelValue": [value: string[]]
}>()
const search = ref("")
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return props.projects
return props.projects.filter(
(p) => p.name.toLowerCase().includes(q) || (p.clientName ?? "").toLowerCase().includes(q),
)
})
const selectedProjects = computed(() =>
props.modelValue
.map((id) => props.projects.find((p) => p.id === id))
.filter((p): p is Project => Boolean(p)),
)
function isSelected(id: string): boolean {
return props.modelValue.includes(id)
}
function toggle(id: string) {
const next = isSelected(id) ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id]
emit("update:modelValue", next)
}
function remove(id: string) {
emit(
"update:modelValue",
props.modelValue.filter((x) => x !== id),
)
}
</script>
<template>
<select
class="select select-bordered w-72 max-w-full"
:value="modelValue"
:disabled="disabled"
aria-label="Select a project"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option v-if="projects.length === 0" value="">No projects available</option>
<option v-for="p in projects" :key="p.id" :value="p.id">
{{ p.name }}<template v-if="p.clientName"> {{ p.clientName }}</template>
</option>
</select>
<div class="flex items-center gap-2">
<div v-if="selectedProjects.length" class="hidden max-w-md flex-wrap gap-1 lg:flex">
<span v-for="p in selectedProjects" :key="p.id" class="badge badge-primary gap-1">
{{ p.name }}
<button
class="leading-none"
type="button"
:aria-label="`Remove ${p.name}`"
@click="remove(p.id)"
>
</button>
</span>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm" :class="{ 'btn-disabled': disabled }">
Projects
<span v-if="modelValue.length" class="badge badge-sm badge-primary">
{{ modelValue.length }}
</span>
</div>
<div
tabindex="0"
class="dropdown-content z-10 mt-2 w-80 rounded-box bg-base-100 p-2 shadow-lg"
>
<input
v-model="search"
type="text"
placeholder="Search projects…"
class="input input-sm input-bordered mb-2 w-full"
aria-label="Search projects"
/>
<ul class="menu max-h-72 flex-nowrap gap-0 overflow-y-auto p-0">
<li v-for="p in filtered" :key="p.id">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="isSelected(p.id)"
@change="toggle(p.id)"
/>
<span class="flex-1 truncate">
{{ p.name }}
<span v-if="p.clientName" class="opacity-60">· {{ p.clientName }}</span>
</span>
</label>
</li>
<li v-if="filtered.length === 0" class="px-2 py-1 text-sm opacity-60">
No matching projects
</li>
</ul>
</div>
</div>
</div>
</template>

View File

@@ -15,6 +15,7 @@ export interface Member {
}
export type DataSource = "napta" | "fixture"
export type SlackStatus = "ok" | "degraded" | "unconfigured"
export interface ProjectsResponse {
source: DataSource
@@ -23,5 +24,6 @@ export interface ProjectsResponse {
export interface MembersResponse {
source: DataSource
slack: SlackStatus
members: Member[]
}