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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
backend
|
||||
data
|
||||
.zed
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
5
.oxfmtrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
}
|
||||
11
.oxlintrc.json
Normal file
11
.oxlintrc.json
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
31
.zed/settings.json
Normal file
31
.zed/settings.json
Normal 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
11
Dockerfile
Normal 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
55
README.md
Normal 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
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
.env.*
|
||||
16
backend/.env.example
Normal file
16
backend/.env.example
Normal 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
11
backend/Dockerfile
Normal 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
19
backend/package.json
Normal 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
62
backend/pnpm-lock.yaml
generated
Normal 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
19
backend/src/config.ts
Normal 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
47
backend/src/db.ts
Normal 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
47
backend/src/fixtures.ts
Normal 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
35
backend/src/index.ts
Normal 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
15
backend/src/napta.ts
Normal 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
9
backend/src/slack.ts
Normal 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
17
backend/src/types.ts
Normal 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
19
backend/tsconfig.json
Normal 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
22
docker-compose.yml
Normal 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
13
index.html
Normal 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
24
nginx.conf
Normal 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
31
package.json
Normal 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
1468
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
public/favicon.svg
Normal file
24
public/favicon.svg
Normal 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
87
src/App.vue
Normal 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
18
src/api.ts
Normal 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`)
|
||||
}
|
||||
37
src/assets/icons/README.md
Normal file
37
src/assets/icons/README.md
Normal 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.
|
||||
30
src/components/MemberCard.vue
Normal file
30
src/components/MemberCard.vue
Normal 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>
|
||||
39
src/components/MemberGrid.vue
Normal file
39
src/components/MemberGrid.vue
Normal 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>
|
||||
28
src/components/ProjectPicker.vue
Normal file
28
src/components/ProjectPicker.vue
Normal 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
5
src/main.ts
Normal 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
17
src/style.css
Normal 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
27
src/types.ts
Normal 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
17
tsconfig.app.json
Normal 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
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal 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
22
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user