chore: initial scaffold

Vite + Vue 3 + DaisyUI SPA (project picker + Slack avatar member grid) with a
TypeScript/Node (Hono) backend proxying Napta + Slack, SQLite avatar cache via
node:sqlite, and docker-compose for Coolify deploy.
This commit is contained in:
Julien Calixte
2026-06-26 15:26:20 +01:00
commit 528251f176
40 changed files with 2410 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.DS_Store
backend
data
.zed

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Runtime data + secrets
data/
*.db
.env
.env.*
!.env.example

5
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": false
}

11
.oxlintrc.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error"
},
"rules": {},
"env": {
"builtin": true
}
}

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

31
.zed/settings.json Normal file
View File

@@ -0,0 +1,31 @@
{
"languages": {
"TypeScript": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
},
"JavaScript": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
},
"Vue.js": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
}
}
}

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# photofetch
Deployed at https://photofetch.apoena.dev
Pick a **Napta project** and get a grid of the people staffed on it — each with
their **Slack profile picture**, first name, last name, and role. People are
matched between Napta and Slack by **email**.
## Architecture
- **frontend** (repo root) — Vite + Vue 3 + TypeScript + Tailwind v4 + DaisyUI SPA.
- **backend/** — TypeScript/Node API (Hono) that proxies Napta + Slack so their
tokens never reach the browser. Runs `.ts` directly on Node 24 (no build step);
SQLite via the built-in `node:sqlite` module (avatar cache at `data/app.db`).
Serves demo fixtures until live credentials are configured.
In production, nginx serves the built SPA and proxies `/api` to the backend
service (see `nginx.conf` + `docker-compose.yml`).
## Develop
```bash
pnpm install
pnpm dev # frontend on :5173 (proxies /api -> :8000)
cd backend && pnpm install && pnpm dev # API on :8000
# …or run the whole stack in containers:
docker compose up --build
```
Deploy gates (run before pushing):
```bash
pnpm lint # oxlint (pnpm lint:fix to autofix)
pnpm fmt:check # oxfmt (pnpm fmt to format)
pnpm build # vue-tsc + vite build, no warnings
cd backend && pnpm typecheck
```
## Configuration (live data)
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`) |
| `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
"demo data" banner.
## Deploy
Pushes to `main` are picked up by Coolify at https://platform.apoena.dev and
deployed to https://photofetch.apoena.dev.

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
data
.env
.env.*

16
backend/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Backend configuration. Copy to .env for local dev (gitignored), or set these
# as environment variables on the Coolify app for production.
PORT=8000
# Napta API — required for live data. Without it, the API serves demo fixtures.
# Base URL + token: see https://app.napta.io (Settings → API).
NAPTA_API_TOKEN=
NAPTA_BASE_URL=https://app.napta.io/api/v1
# Slack bot token (xoxb-...) with scopes: users:read, users:read.email
# Used to resolve each person's profile picture by email.
SLACK_BOT_TOKEN=
# SQLite location (avatar cache). Matches the Coolify persistent volume.
DB_PATH=data/app.db

11
backend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
# Node 24 runs TypeScript directly (type stripping) and ships node:sqlite
# built in — so no build step, no tsx, no native sqlite module needed.
FROM node:24-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --prod --frozen-lockfile
COPY . .
ENV PORT=8000
ENV DB_PATH=/app/data/app.db
EXPOSE 8000
CMD ["node", "src/index.ts"]

19
backend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "photofetch-backend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.ts",
"start": "node src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^2.0.6",
"hono": "^4.12.27"
},
"devDependencies": {
"@types/node": "^26.0.1",
"typescript": "^6.0.3"
}
}

62
backend/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,62 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@hono/node-server':
specifier: ^2.0.6
version: 2.0.6(hono@4.12.27)
hono:
specifier: ^4.12.27
version: 4.12.27
devDependencies:
'@types/node':
specifier: ^26.0.1
version: 26.0.1
typescript:
specifier: ^6.0.3
version: 6.0.3
packages:
'@hono/node-server@2.0.6':
resolution: {integrity: sha512-7DeRlKG57JDBNZ5Qj2jwVdgwQy4b0tLubRLl3zCf91/rCf9i7p1V5FtW/yWibm1uUHE493ts9ZXH/7g/LQWl+g==}
engines: {node: '>=20'}
peerDependencies:
hono: ^4
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
hono@4.12.27:
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
engines: {node: '>=16.9.0'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
snapshots:
'@hono/node-server@2.0.6(hono@4.12.27)':
dependencies:
hono: 4.12.27
'@types/node@26.0.1':
dependencies:
undici-types: 8.3.0
hono@4.12.27: {}
typescript@6.0.3: {}
undici-types@8.3.0: {}

19
backend/src/config.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface Config {
port: number
naptaApiToken: string | null
naptaBaseUrl: string
slackBotToken: string | null
}
export const config: Config = {
port: Number(process.env.PORT ?? 8000),
naptaApiToken: process.env.NAPTA_API_TOKEN || null,
naptaBaseUrl: process.env.NAPTA_BASE_URL || "https://app.napta.io/api/v1",
slackBotToken: process.env.SLACK_BOT_TOKEN || null,
}
// Live data needs BOTH a Napta token (to list projects/people) and a Slack
// token (to resolve avatars). Without both, the API serves demo fixtures.
export const hasLiveCredentials: boolean = Boolean(
config.naptaApiToken && config.slackBotToken,
)

47
backend/src/db.ts Normal file
View File

@@ -0,0 +1,47 @@
import { DatabaseSync } from "node:sqlite"
import { mkdirSync } from "node:fs"
import { dirname } from "node:path"
const dbPath = process.env.DB_PATH || "data/app.db"
mkdirSync(dirname(dbPath), { recursive: true })
export const db = new DatabaseSync(dbPath)
// Cache of email -> Slack avatar URL, so we don't hit Slack on every request.
// image_url is nullable: a NULL row means "looked up, no Slack match" (also
// worth caching, to avoid re-querying people who aren't in Slack).
db.exec(`
CREATE TABLE IF NOT EXISTS slack_avatar_cache (
email TEXT PRIMARY KEY,
image_url TEXT,
cached_at INTEGER NOT NULL
)
`)
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
interface CacheRow {
image_url: string | null
cached_at: number
}
// Returns the cached URL (which may be null = known-no-match), or undefined
// when there is no fresh cache entry and the caller should query Slack.
export function getCachedAvatar(email: string): string | null | undefined {
const row = db
.prepare("SELECT image_url, cached_at FROM slack_avatar_cache WHERE email = ?")
.get(email.toLowerCase()) as CacheRow | undefined
if (!row) return undefined
if (Date.now() - row.cached_at > CACHE_TTL_MS) return undefined
return row.image_url
}
export function setCachedAvatar(email: string, imageUrl: string | null): void {
db.prepare(
`INSERT INTO slack_avatar_cache (email, image_url, cached_at)
VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
image_url = excluded.image_url,
cached_at = excluded.cached_at`,
).run(email.toLowerCase(), imageUrl, Date.now())
}

47
backend/src/fixtures.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { Member, Project } from "./types.ts"
// Demo data served when live Napta/Slack credentials are not configured. Lets
// the UI render a realistic team grid out of the box. imageUrl is null so the
// frontend shows initials avatars (the same fallback used for real members who
// have no Slack match).
export const fixtureProjects: Project[] = [
{ id: "demo-apollo", name: "Apollo Platform", clientName: "Acme Corp" },
{ id: "demo-zephyr", name: "Zephyr Mobile App", clientName: "Globex" },
]
function member(
id: string,
firstName: string,
lastName: string,
role: string,
): Member {
return {
id,
firstName,
lastName,
role,
email: `${firstName}.${lastName}@example.com`.toLowerCase(),
imageUrl: null,
slackMatched: false,
}
}
const membersByProject: Record<string, Member[]> = {
"demo-apollo": [
member("1", "Ada", "Lovelace", "Tech Lead"),
member("2", "Alan", "Turing", "Backend Engineer"),
member("3", "Grace", "Hopper", "Engineering Manager"),
member("4", "Katherine", "Johnson", "Data Scientist"),
member("5", "Dennis", "Ritchie", "Platform Engineer"),
],
"demo-zephyr": [
member("6", "Margaret", "Hamilton", "Product Owner"),
member("7", "Linus", "Torvalds", "Mobile Lead"),
member("8", "Barbara", "Liskov", "iOS Engineer"),
],
}
export function fixtureMembers(projectId: string): Member[] {
return membersByProject[projectId] ?? []
}

35
backend/src/index.ts Normal file
View File

@@ -0,0 +1,35 @@
import { serve } from "@hono/node-server"
import { Hono } from "hono"
import { logger } from "hono/logger"
import { config, hasLiveCredentials } from "./config.ts"
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
import { enrichWithSlackAvatars } from "./slack.ts"
import "./db.ts" // initialise the SQLite schema at boot
const app = new Hono()
app.use("*", logger())
app.get("/api/health", (c) => c.json({ status: "ok" }))
app.get("/api/projects", async (c) => {
if (!hasLiveCredentials) {
return c.json({ source: "fixture", projects: fixtureProjects })
}
const projects = await fetchNaptaProjects()
return c.json({ source: "napta", projects })
})
app.get("/api/projects/:id/members", async (c) => {
const id = c.req.param("id")
if (!hasLiveCredentials) {
return c.json({ source: "fixture", members: fixtureMembers(id) })
}
const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id))
return c.json({ source: "napta", members })
})
console.log(
`photofetch backend listening on :${config.port} (live credentials: ${hasLiveCredentials})`,
)
serve({ fetch: app.fetch, port: config.port })

15
backend/src/napta.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Member, Project } from "./types.ts"
// Real Napta integration. Implemented in Step 11 (build-out) once the exact
// Napta API endpoints, auth header, and response shapes are confirmed in the
// walk-with-me design session. Until then these throw, and the route layer
// only calls them when live credentials are present — so the default
// experience (no tokens) serves fixtures instead of erroring.
export async function fetchNaptaProjects(): Promise<Project[]> {
throw new Error("Napta integration not implemented yet (Step 11)")
}
export async function fetchNaptaProjectMembers(_projectId: string): Promise<Member[]> {
throw new Error("Napta integration not implemented yet (Step 11)")
}

9
backend/src/slack.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Member } from "./types.ts"
// Given Napta members (which carry emails), fill in imageUrl from Slack by
// matching on email — caching results in slack_avatar_cache (see db.ts).
// Implemented in Step 11; for now it's a no-op pass-through so the pipeline
// is wired end-to-end and members render with initials fallbacks.
export async function enrichWithSlackAvatars(members: Member[]): Promise<Member[]> {
return members
}

17
backend/src/types.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface Project {
id: string
name: string
clientName?: string
}
export interface Member {
id: string
firstName: string
lastName: string
role: string
email: string
imageUrl: string | null
slackMatched: boolean
}
export type DataSource = "napta" | "fixture"

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["esnext"],
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
web:
build: .
ports:
- "80:80"
depends_on:
- api
api:
build: ./backend
environment:
- PORT=8000
- DB_PATH=/app/data/app.db
# Set these as env vars on the Coolify app to switch from demo fixtures
# to live data. Empty -> the backend serves fixtures.
- NAPTA_API_TOKEN=${NAPTA_API_TOKEN:-}
- NAPTA_BASE_URL=${NAPTA_BASE_URL:-https://app.napta.io/api/v1}
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-}
volumes:
- ./data:/app/data
ports:
- "8000:8000"

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>photofetch</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

24
nginx.conf Normal file
View File

@@ -0,0 +1,24 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API calls to the backend service (docker-compose service name "api").
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "photofetch",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
},
"dependencies": {
"daisyui": "^5.5.23",
"vue": "^3.5.38"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"oxfmt": "^0.56.0",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.1",
"typescript": "~6.0.2",
"vite": "^8.1.0",
"vue-tsc": "^3.3.5"
}
}

1468
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

24
public/favicon.svg Normal file
View File

@@ -0,0 +1,24 @@
<!--
tags: [community, team, cluster, society, gathering, crowd, network, collective, assembly, fellowship]
version: "2.13"
unicode: "fa21"
category: System
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#4A154B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1" />
<path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M17 10h2a2 2 0 0 1 2 2v1" />
<path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M3 13v-1a2 2 0 0 1 2 -2h2" />
</svg>

After

Width:  |  Height:  |  Size: 652 B

87
src/App.vue Normal file
View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue"
import type { DataSource, Member, Project } from "@/types"
import { fetchMembers, fetchProjects } from "@/api"
import ProjectPicker from "@/components/ProjectPicker.vue"
import MemberGrid from "@/components/MemberGrid.vue"
const projects = ref<Project[]>([])
const selectedProjectId = ref<string>("")
const members = ref<Member[]>([])
const source = ref<DataSource>("napta")
const projectsError = ref<string | null>(null)
const membersError = ref<string | null>(null)
const loadingMembers = ref(false)
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)
}
})
watch(selectedProjectId, async (id) => {
if (!id) {
members.value = []
return
}
loadingMembers.value = true
membersError.value = null
try {
const res = await fetchMembers(id)
members.value = res.members
source.value = res.source
} catch (err) {
membersError.value = err instanceof Error ? err.message : String(err)
members.value = []
} finally {
loadingMembers.value = false
}
})
</script>
<template>
<div class="min-h-screen bg-base-200">
<header class="navbar gap-3 bg-base-100 px-4 shadow-sm sm:px-6">
<div class="flex flex-1 items-center gap-2">
<img src="/favicon.svg" alt="" class="size-7" />
<span class="text-xl font-semibold">photofetch</span>
</div>
<div class="flex-none">
<ProjectPicker
v-model="selectedProjectId"
: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">
<span>
Showing demo data. Set <code>NAPTA_API_TOKEN</code> and <code>SLACK_BOT_TOKEN</code> on
the server to load your real projects.
</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"
/>
</main>
</div>
</template>

18
src/api.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { MembersResponse, ProjectsResponse } from "@/types"
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url)
if (!res.ok) {
const body = await res.text()
throw new Error(`${res.status} ${res.statusText}${body ? `: ${body}` : ""}`)
}
return (await res.json()) as T
}
export function fetchProjects(): Promise<ProjectsResponse> {
return getJson<ProjectsResponse>("/api/projects")
}
export function fetchMembers(projectId: string): Promise<MembersResponse> {
return getJson<MembersResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`)
}

View File

@@ -0,0 +1,37 @@
# Icons
Reusable in-app icons. Source: https://tabler.io/icons (outline variant by default).
## Add an icon
1. Find the icon on https://tabler.io/icons, click it, copy the SVG (or download).
2. Save it here as `<slug>.svg` — same slug Tabler uses (`bolt.svg`, `qrcode.svg`).
3. Keep `stroke="currentColor"` in the SVG so colour follows Tailwind classes.
## Use an icon
Static colour (cheapest, no extra component):
```vue
<img src="@/assets/icons/bolt.svg" alt="" class="size-5" />
```
Dynamic colour (needs `currentColor` to flow through — paste the SVG inline as a component):
```vue
<template>
<svg
class="size-5 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- paste paths from the Tabler SVG here -->
</svg>
</template>
```
The favicon at `public/favicon.svg` was generated from this same icon set with `currentColor` replaced by the app's primary hex at scaffold time.

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { Member } from "@/types"
const props = defineProps<{ member: Member }>()
const initials =
`${props.member.firstName.charAt(0)}${props.member.lastName.charAt(0)}`.toUpperCase()
const fullName = `${props.member.firstName} ${props.member.lastName}`
</script>
<template>
<div class="card card-compact bg-base-100 shadow-sm">
<figure class="pt-5">
<div v-if="member.imageUrl" class="avatar">
<div class="w-24 rounded-full ring ring-primary ring-offset-2 ring-offset-base-100">
<img :src="member.imageUrl" :alt="fullName" loading="lazy" />
</div>
</div>
<div v-else class="avatar avatar-placeholder">
<div class="w-24 rounded-full bg-primary text-primary-content">
<span class="text-2xl">{{ initials }}</span>
</div>
</div>
</figure>
<div class="card-body items-center text-center">
<h2 class="card-title text-base">{{ fullName }}</h2>
<p class="text-sm opacity-70">{{ member.role }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { Member } from "@/types"
import MemberCard from "@/components/MemberCard.vue"
defineProps<{
members: Member[]
loading: boolean
error: string | null
projectSelected: boolean
}>()
</script>
<template>
<div v-if="error" role="alert" class="alert alert-error">
<span>Could not load members: {{ error }}</span>
</div>
<div v-else-if="loading" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<div v-for="n in 8" :key="n" class="card bg-base-100 shadow-sm">
<div class="card-body items-center gap-3">
<div class="skeleton size-24 rounded-full"></div>
<div class="skeleton h-4 w-24"></div>
<div class="skeleton h-3 w-16"></div>
</div>
</div>
</div>
<div v-else-if="!projectSelected" class="py-20 text-center opacity-60">
<p>Select a project to see its team.</p>
</div>
<div v-else-if="members.length === 0" class="py-20 text-center opacity-60">
<p>No people found on this project.</p>
</div>
<div v-else class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<MemberCard v-for="m in members" :key="m.id" :member="m" />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { Project } from "@/types"
defineProps<{
projects: Project[]
modelValue: string
disabled?: boolean
}>()
defineEmits<{
"update:modelValue": [value: string]
}>()
</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>
</template>

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from "vue"
import "./style.css"
import App from "./App.vue"
createApp(App).mount("#app")

17
src/style.css Normal file
View File

@@ -0,0 +1,17 @@
/* The font @import MUST come first — before @import "tailwindcss" — because
Tailwind inlines its import into real CSS rules, and the CSS spec requires
@import to precede all other rules. If it comes second, the production build
warns ("@import must precede all rules…") and browsers silently DROP the
font import, so the custom font never loads. */
@import url("https://api.fonts.coollabs.io/css2?family=Inter:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "light";
default: true;
--color-primary: #4a154b;
}
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}

27
src/types.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface Project {
id: string
name: string
clientName?: string
}
export interface Member {
id: string
firstName: string
lastName: string
role: string
email: string
imageUrl: string | null
slackMatched: boolean
}
export type DataSource = "napta" | "fixture"
export interface ProjectsResponse {
source: DataSource
projects: Project[]
}
export interface MembersResponse {
source: DataSource
members: Member[]
}

17
tsconfig.app.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

4
tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

23
tsconfig.node.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

22
vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite"
import { fileURLToPath, URL } from "node:url"
import vue from "@vitejs/plugin-vue"
import tailwindcss from "@tailwindcss/vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
})