Merge pull request #69 from artslidd/develop

feat: dynamic PWA manifest with org logos + mobile responsiveness
This commit is contained in:
Arthur Belleville 2026-04-03 12:49:21 +02:00 committed by GitHub
commit dc35fd1d8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 6286 additions and 270 deletions

View file

@ -32,6 +32,7 @@
"luxon": "^3.7.2",
"multer": "^2.0.2",
"nodemailer": "^7.0.4",
"sharp": "^0.34.5",
"stream-chat": "^9.8.0",
"stripe": "^20.0.0",
"ts-node": "^10.9.2"

View file

@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resizeOrgIcon, buildOrgIconKey, ICON_SIZES, ORG_ICONS_BUCKET } from "./orgIcons.js";
describe("buildOrgIconKey", () => {
it("builds the correct R2 key for a given org and size", () => {
expect(buildOrgIconKey(42, 192)).toBe("org-icons/42/icon-192.png");
});
it("builds the correct key for maskable variant", () => {
expect(buildOrgIconKey(42, 512, true)).toBe("org-icons/42/icon-512-maskable.png");
});
});
describe("ICON_SIZES", () => {
it("includes all required PWA icon sizes", () => {
const sizes = ICON_SIZES.map((s) => s.size);
expect(sizes).toContain(16);
expect(sizes).toContain(32);
expect(sizes).toContain(180);
expect(sizes).toContain(192);
expect(sizes).toContain(512);
});
it("includes a maskable 512 variant", () => {
const maskable = ICON_SIZES.find((s) => s.maskable);
expect(maskable).toBeDefined();
expect(maskable!.size).toBe(512);
});
});
describe("resizeOrgIcon", () => {
it("returns buffers for all required sizes from a valid PNG input", async () => {
const sharp = (await import("sharp")).default;
const input = await sharp({
create: { width: 512, height: 512, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } },
})
.png()
.toBuffer();
const results = await resizeOrgIcon(input);
expect(results).toHaveLength(ICON_SIZES.length);
for (const result of results) {
expect(result.buffer).toBeInstanceOf(Buffer);
expect(result.buffer.length).toBeGreaterThan(0);
}
});
it("rejects images smaller than 512x512", async () => {
const sharp = (await import("sharp")).default;
const tooSmall = await sharp({
create: { width: 256, height: 256, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 1 } },
})
.png()
.toBuffer();
await expect(resizeOrgIcon(tooSmall)).rejects.toThrow("minimum 512x512");
});
});

View file

@ -0,0 +1,148 @@
import {
DeleteObjectsCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import sharp from "sharp";
export const ORG_ICONS_BUCKET = "web-assets";
export const ICON_SIZES = [
{ size: 16, maskable: false },
{ size: 32, maskable: false },
{ size: 180, maskable: false },
{ size: 192, maskable: false },
{ size: 512, maskable: false },
{ size: 512, maskable: true },
] as const;
export function buildOrgIconKey(orgId: number, size: number, maskable = false): string {
const suffix = maskable ? `-${size}-maskable` : `-${size}`;
return `org-icons/${orgId}/icon${suffix}.png`;
}
export function buildOrgIconBasePath(orgId: number): string {
return `org-icons/${orgId}/`;
}
export interface ResizedIcon {
size: number;
maskable: boolean;
buffer: Buffer;
key: string;
}
export async function resizeOrgIcon(inputBuffer: Buffer): Promise<ResizedIcon[]> {
const metadata = await sharp(inputBuffer).metadata();
if (!metadata.width || !metadata.height || metadata.width < 512 || metadata.height < 512) {
throw new Error("Image must be minimum 512x512 pixels");
}
const results: ResizedIcon[] = [];
for (const { size, maskable } of ICON_SIZES) {
// For maskable icons, add 10% padding (safe zone) with white background
if (maskable) {
const innerSize = Math.round(size * 0.8);
const resized = await sharp(inputBuffer)
.resize(innerSize, innerSize, { fit: "cover" })
.png()
.toBuffer();
const padded = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 },
},
})
.composite([{ input: resized, gravity: "center" }])
.png()
.toBuffer();
results.push({
size,
maskable,
buffer: padded,
key: buildOrgIconKey(0, size, maskable),
});
continue;
}
const buffer = await sharp(inputBuffer).resize(size, size, { fit: "cover" }).png().toBuffer();
results.push({
size,
maskable,
buffer,
key: buildOrgIconKey(0, size, maskable),
});
}
return results;
}
export async function uploadOrgIcons(
s3Client: S3Client,
orgId: number,
inputBuffer: Buffer
): Promise<string> {
const resized = await resizeOrgIcon(inputBuffer);
for (const icon of resized) {
const key = buildOrgIconKey(orgId, icon.size, icon.maskable);
await s3Client.send(
new PutObjectCommand({
Bucket: ORG_ICONS_BUCKET,
Key: key,
Body: icon.buffer,
ContentType: "image/png",
})
);
}
return buildOrgIconBasePath(orgId);
}
export async function deleteOrgIcons(s3Client: S3Client, orgId: number): Promise<void> {
const prefix = buildOrgIconBasePath(orgId);
const listed = await s3Client.send(
new ListObjectsV2Command({
Bucket: ORG_ICONS_BUCKET,
Prefix: prefix,
})
);
if (!listed.Contents || listed.Contents.length === 0) return;
await s3Client.send(
new DeleteObjectsCommand({
Bucket: ORG_ICONS_BUCKET,
Delete: { Objects: listed.Contents.map(({ Key }) => ({ Key })) },
})
);
}
export async function getOrgIcon(
s3Client: S3Client,
orgId: number,
size: number,
maskable: boolean
): Promise<{ buffer: Buffer; contentType: string } | null> {
const key = buildOrgIconKey(orgId, size, maskable);
try {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: ORG_ICONS_BUCKET,
Key: key,
})
);
const body = await response.Body?.transformToByteArray();
if (!body) return null;
return { buffer: Buffer.from(body), contentType: "image/png" };
} catch {
return null;
}
}

View file

@ -1,6 +1,8 @@
import type { S3Client } from "@aws-sdk/client-s3";
import type { Database, Tables } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { getOrgIcon } from "../helpers/orgIcons.js";
import {
type EventTypeConfig,
type Exception,
@ -125,10 +127,52 @@ const getPublicSlots = factory.createHandlers(async (c) => {
});
});
const getOrgIconHandler = factory.createHandlers(async (c) => {
const orgIdStr = c.req.param("orgId");
const filename = c.req.param("filename");
const orgId = Number.parseInt(orgIdStr, 10);
if (Number.isNaN(orgId) || !filename) {
return c.json({ error: "Invalid request" }, 400);
}
// Parse filename: "icon-192.png" or "icon-512-maskable.png"
const match = filename.match(/^icon-(\d+)(-maskable)?\.png$/);
if (!match) {
return c.json({ error: "Invalid icon filename" }, 400);
}
const size = Number.parseInt(match[1], 10);
const maskable = !!match[2];
const s3Client = c.get("s3_client") as S3Client;
const result = await getOrgIcon(s3Client, orgId, size, maskable);
if (!result) {
// Fallback: redirect to default PWA icon
const defaultPath = maskable
? "/pwa-icons/pwa-512x512-maskable.png"
: size <= 32
? `/pwa-icons/favicon-${size}x${size}.png`
: size === 180
? "/pwa-icons/apple-touch-icon-180x180.png"
: `/pwa-icons/pwa-${size}x${size}.png`;
return c.redirect(defaultPath, 302);
}
return new Response(new Uint8Array(result.buffer), {
headers: {
"Content-Type": result.contentType,
"Cache-Control": "public, max-age=86400",
},
});
});
export const getPublicRouter = () => {
const publicRouter = new Hono<BaseEnv>();
publicRouter.get("/slots/:shortUserId/:standardName", ...getPublicSlots);
publicRouter.get("/org-icons/:orgId/:filename", ...getOrgIconHandler);
return publicRouter;
};

View file

@ -4,6 +4,7 @@ import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { getOrganizationBillingState } from "../helpers/billing.js";
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
import { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js";
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
@ -295,7 +296,7 @@ const getOrganization = factory.createHandlers(async (c) => {
const { data: organization, error: organizationError } = await supabase
.from("organizations")
.select("id, name")
.select("id, name, logo_url")
.eq("id", organizationId)
.single();
@ -419,6 +420,7 @@ const getOrganization = factory.createHandlers(async (c) => {
organization: {
id: organization.id,
name: organization.name,
logo_url: organization.logo_url ?? null,
plan,
member_count: members?.length || 0,
tablo_count: tabloCount || 0,
@ -439,13 +441,8 @@ const getOrganization = factory.createHandlers(async (c) => {
const updateOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
const body = await c.req.json();
const rawName = typeof body?.name === "string" ? body.name : "";
const name = rawName.trim();
if (name.length < 2 || name.length > 100) {
return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400);
}
const { data: profile, error: profileError } = await supabase
.from("profiles")
@ -461,13 +458,54 @@ const updateOrganization = factory.createHandlers(async (c) => {
return c.json({ error: "Temporary users cannot update organization settings" }, 403);
}
const { error: updateError } = await supabase
.from("organizations")
.update({ name })
.eq("id", profile.organization_id);
const organizationId = profile.organization_id;
const updateData: Record<string, unknown> = {};
if (updateError) {
return c.json({ error: updateError.message }, 500);
// Handle name update
if (body?.name !== undefined) {
const rawName = typeof body.name === "string" ? body.name : "";
const name = rawName.trim();
if (name.length < 2 || name.length > 100) {
return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400);
}
updateData.name = name;
}
// Handle logo upload
if (body?.logo !== undefined) {
if (body.logo === null) {
// Remove logo
await deleteOrgIcons(s3Client, organizationId);
updateData.logo_url = null;
} else if (body.logo?.content && body.logo?.contentType) {
const { content, contentType } = body.logo;
// Validate content type
const allowedTypes = ["image/png", "image/jpeg", "image/webp"];
if (!allowedTypes.includes(contentType)) {
return c.json({ error: "Logo must be PNG, JPEG, or WebP" }, 400);
}
const imageBuffer = Buffer.from(content, "base64");
try {
const basePath = await uploadOrgIcons(s3Client, organizationId, imageBuffer);
updateData.logo_url = basePath;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to process logo";
return c.json({ error: message }, 400);
}
}
}
if (Object.keys(updateData).length > 0) {
const { error: updateError } = await supabase
.from("organizations")
.update(updateData)
.eq("id", organizationId);
if (updateError) {
return c.json({ error: updateError.message }, 500);
}
}
return c.json({ message: "Organization updated successfully" });

View file

@ -8,7 +8,7 @@ export default defineConfig({
globalSetup: ["./src/__tests__/globalSetup.ts"],
testTimeout: 30000,
hookTimeout: 60000,
include: ["src/__tests__/**/*.test.ts"],
include: ["src/__tests__/**/*.test.ts", "src/**/*.test.ts"],
exclude: ["node_modules", "dist"],
reporters: ["verbose"],
pool: "forks",

View file

@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
testTimeout: 30000,
include: ["src/helpers/**/*.test.ts"],
exclude: ["node_modules", "dist"],
reporters: ["verbose"],
pool: "forks",
},
resolve: {
alias: {
"@": "/src",
},
},
});

View file

@ -2,8 +2,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpg+xml" href="/public/icon.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" sizes="32x32" href="/pwa-icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/pwa-icons/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/pwa-icons/apple-touch-icon-180x180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1e1b2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>XTablo</title>
</head>
<body>

View file

@ -39,6 +39,7 @@
"@types/node": "^22.13.10",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/sharp": "^0.32.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.3.4",
@ -56,6 +57,7 @@
"react-aria-components": "^1.7.0",
"react-dom": "19.0.0",
"rollup-plugin-visualizer": "^5.14.0",
"sharp": "^0.34.5",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.15",
"tailwindcss-animate": "^1.0.7",
@ -63,8 +65,10 @@
"typescript": "^5.7.0",
"typescript-eslint": "^8.26.1",
"vite": "^6.2.2",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"workbox-window": "^7.4.0",
"wrangler": "^4.24.3"
},
"dependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View file

@ -0,0 +1,74 @@
import sharp from "sharp";
import path from "node:path";
import fs from "node:fs";
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
const OUTPUT_DIR = path.join(PUBLIC_DIR, "pwa-icons");
const ICON_SIZES = [
{ size: 16, name: "favicon-16x16.png" },
{ size: 32, name: "favicon-32x32.png" },
{ size: 180, name: "apple-touch-icon-180x180.png" },
{ size: 192, name: "pwa-192x192.png" },
{ size: 512, name: "pwa-512x512.png" },
{ size: 512, name: "pwa-512x512-maskable.png", maskable: true },
];
// Maskable icons need 10% safe zone padding (per spec).
// We add a colored background and shrink the source to 80% to stay within the safe zone.
const MASKABLE_PADDING_RATIO = 0.1;
const MASKABLE_BG_COLOR = "#ffffff";
async function generateIcons(sourceFile: string) {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
for (const icon of ICON_SIZES) {
const outputPath = path.join(OUTPUT_DIR, icon.name);
if (icon.maskable) {
// Create maskable icon with padding and background
const innerSize = Math.round(icon.size * (1 - MASKABLE_PADDING_RATIO * 2));
const offset = Math.round(icon.size * MASKABLE_PADDING_RATIO);
const resizedIcon = await sharp(sourceFile)
.resize(innerSize, innerSize, { fit: "contain" })
.toBuffer();
await sharp({
create: {
width: icon.size,
height: icon.size,
channels: 4,
background: MASKABLE_BG_COLOR,
},
})
.composite([{ input: resizedIcon, left: offset, top: offset }])
.png()
.toFile(outputPath);
} else {
await sharp(sourceFile)
.resize(icon.size, icon.size, { fit: "contain" })
.png()
.toFile(outputPath);
}
console.log(`Generated: ${icon.name} (${icon.size}x${icon.size})`);
}
}
// Determine source: pass "staging" as first arg for staging icons
const variant = process.argv[2];
const sourceFile =
variant === "staging"
? path.join(PUBLIC_DIR, "staging_icon.jpg")
: path.join(PUBLIC_DIR, "icon.jpg");
if (!fs.existsSync(sourceFile)) {
console.error(`Source icon not found: ${sourceFile}`);
process.exit(1);
}
console.log(`Generating PWA icons from: ${sourceFile}`);
generateIcons(sourceFile).then(() => console.log("Done!"));

View file

@ -3,6 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
import { CookieBanner } from "./components/CookieBanner";
import { InstallBanner } from "./components/InstallBanner";
import { PendingSignupCheckout } from "./components/PendingSignupCheckout";
import { PlanAnnouncement } from "./components/PlanAnnouncement";
import { TrialUpsellModal } from "./components/TrialUpsellModal";
@ -31,6 +32,7 @@ const Routes = () => {
return (
<UserStoreProvider>
<UpgradeBlockProvider>
<InstallBanner />
<PendingSignupCheckout />
<PlanAnnouncement />
<UpgradePanel />

View file

@ -32,7 +32,7 @@ export function ActionCard({
onClick={disabled ? undefined : onClick}
disabled={disabled}
className={cn(
"h-fit p-3 rounded-2xl text-left transition-all",
"h-fit p-3 rounded-2xl text-left transition-all min-h-[56px]",
disabled
? "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 opacity-50 cursor-not-allowed"
: isSelected

View file

@ -49,9 +49,9 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
};
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="fixed inset-0 bg-black/80 flex items-end sm:items-center justify-center z-50">
<ClickOutside onClickOutside={handleClose}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl min-w-96 mx-4">
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-lg shadow-xl p-4 sm:p-6 w-full sm:max-w-4xl sm:mx-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
{t("modals:createTablo.title")}
</h2>
@ -81,17 +81,17 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
</div>
{/* Modal Actions */}
<div className="flex justify-end space-x-3 mt-6">
<div className="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3 mt-6">
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
className="px-4 py-3 sm:py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg min-h-[44px]"
onClick={handleClose}
>
{t("common:buttons.cancel")}
</button>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-3 sm:py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px]"
onClick={handleCreate}
disabled={!newTabloName.trim() || creationMode === "image"}
>

View file

@ -18,7 +18,7 @@ export function DashboardActionCards({
const { t } = useTranslation("pages");
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-5">
<ActionCard
icon={<FolderPlus className="w-6 h-6" />}
label={t("dashboard.actionCards.createProject.label")}

View file

@ -56,7 +56,7 @@ function TaskRow({
return (
<div
className="grid grid-cols-[auto_1fr_1fr_auto_auto] items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
onClick={() => {
if (task.tablos) {
navigate(`/tablos/${task.tablos.id}?section=tasks`);
@ -66,7 +66,7 @@ function TaskRow({
{/* Checkbox */}
<button
className={cn(
"w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0",
"w-8 h-8 min-h-[44px] min-w-[44px] rounded-full border-2 flex items-center justify-center shrink-0",
isDone
? "bg-purple-600 border-purple-600"
: "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500"
@ -79,48 +79,48 @@ function TaskRow({
{isDone && <CheckCircle2 className="w-4 h-4 text-white" />}
</button>
{/* Title */}
<p
className={cn(
"text-sm font-medium truncate",
isDone
? "line-through text-gray-400 dark:text-gray-500"
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
</p>
{/* Tablo */}
<div className="flex items-center gap-2 min-w-0">
{task.tablos && (
<>
<div
className={cn(
"w-6 h-6 rounded-lg flex items-center justify-center text-xs shrink-0",
task.tablos.color || "bg-gray-400"
)}
>
<span className="text-white font-bold text-[10px]">
{task.tablos.name.charAt(0).toUpperCase()}
{/* Title + Tablo (stacked on mobile) */}
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-medium truncate",
isDone
? "line-through text-gray-400 dark:text-gray-500"
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
</p>
<div className="flex items-center gap-2 mt-1">
{task.tablos && (
<>
<div
className={cn(
"w-4 h-4 rounded flex items-center justify-center text-xs shrink-0",
task.tablos.color || "bg-gray-400"
)}
>
<span className="text-white font-bold text-[8px]">
{task.tablos.name.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 truncate">
{task.tablos.name}
</span>
</div>
<span className="text-sm text-gray-700 dark:text-gray-300 hidden sm:inline truncate">
{task.tablos.name}
</>
)}
{formattedDate && (
<span className="text-xs text-gray-400 dark:text-gray-500 hidden sm:inline whitespace-nowrap">
{formattedDate}
</span>
</>
)}
)}
</div>
</div>
{/* Date */}
<span className="text-sm text-gray-500 dark:text-gray-400 hidden md:inline whitespace-nowrap">
{formattedDate}
</span>
{/* Status badge */}
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap",
"px-2 sm:px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap shrink-0",
badge.className
)}
>
@ -151,24 +151,22 @@ export function DashboardTaskList() {
return (
<>
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between px-4 py-5 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
<div className="flex items-center justify-between px-4 py-4 sm:py-5 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-gray-100">
{t("dashboard.taskList.title")}
</h2>
<button
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
className="flex items-center gap-2 px-4 py-2.5 min-h-[44px] bg-white dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
onClick={() => setIsTaskModalOpen(true)}
>
<Plus className="w-4 h-4" />
<span>{t("dashboard.taskList.addTask")}</span>
</button>
</div>
<div className="overflow-x-auto">
<div className="min-w-[600px]">
{myTasks.map((task) => (
<TaskRow key={task.id} task={task} onToggleDone={handleToggleDone} />
))}
</div>
<div>
{myTasks.map((task) => (
<TaskRow key={task.id} task={task} onToggleDone={handleToggleDone} />
))}
</div>
</div>

View file

@ -24,9 +24,9 @@ export const DeleteTabloModal = ({
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="fixed inset-0 bg-black/60 flex items-end sm:items-center justify-center z-50">
<ClickOutside onClickOutside={onClose}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md mx-4">
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-lg shadow-xl p-4 sm:p-6 w-full sm:max-w-md sm:mx-4">
{/* Header */}
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mr-4">
@ -70,10 +70,10 @@ export const DeleteTabloModal = ({
</div>
{/* Actions */}
<div className="flex justify-end space-x-3">
<div className="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3">
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
className="px-4 py-3 sm:py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors min-h-[44px]"
onClick={onClose}
disabled={isDeleting}
>
@ -81,7 +81,7 @@ export const DeleteTabloModal = ({
</button>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="px-4 py-3 sm:py-2.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-h-[44px]"
onClick={handleConfirm}
disabled={isDeleting}
>

View file

@ -0,0 +1,128 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock the hook so we can control its return values
const mockPromptInstall = vi.fn();
const mockDismiss = vi.fn();
vi.mock("../hooks/useInstallPrompt", () => ({
useInstallPrompt: vi.fn(() => ({
canInstall: false,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
})),
}));
import { useInstallPrompt } from "../hooks/useInstallPrompt";
import { InstallBanner } from "./InstallBanner";
const mockUseInstallPrompt = vi.mocked(useInstallPrompt);
describe("InstallBanner", () => {
beforeEach(() => {
mockPromptInstall.mockClear();
mockDismiss.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders nothing when canInstall is false and not iOS", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: false,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when already in standalone mode", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: true,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when dismissed", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: true,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders install banner when canInstall is true", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
expect(screen.getByText(/install/i)).toBeInTheDocument();
});
it("calls promptInstall when install button is clicked", async () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
await userEvent.click(screen.getByRole("button", { name: /install/i }));
expect(mockPromptInstall).toHaveBeenCalledOnce();
});
it("calls dismiss when close button is clicked", async () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
await userEvent.click(screen.getByRole("button", { name: /dismiss|close/i }));
expect(mockDismiss).toHaveBeenCalledOnce();
});
it("renders iOS instructions when isIOS is true and not dismissed", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: false,
isStandalone: false,
isIOS: true,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
expect(screen.getByText(/share/i)).toBeInTheDocument();
expect(screen.getByText(/add to home screen/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,46 @@
import { Download, Share, X } from "lucide-react";
import { useInstallPrompt } from "../hooks/useInstallPrompt";
export function InstallBanner() {
const { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss } =
useInstallPrompt();
// Don't show if already installed, dismissed, or no install option available
if (isStandalone || isDismissed) return null;
if (!canInstall && !isIOS) return null;
return (
<div className="flex items-center gap-3 border-b border-border bg-card px-4 py-2.5 text-sm">
{isIOS ? (
<>
<Share className="size-4 shrink-0 text-muted-foreground" />
<p className="flex-1 text-foreground">
Install XTablo: tap <span className="font-medium">Share</span> then{" "}
<span className="font-medium">Add to Home Screen</span>
</p>
</>
) : (
<>
<Download className="size-4 shrink-0 text-muted-foreground" />
<p className="flex-1 text-foreground">Get the app for a faster, native experience</p>
<button
type="button"
onClick={promptInstall}
className="shrink-0 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
aria-label="Install app"
>
Install
</button>
</>
)}
<button
type="button"
onClick={dismiss}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="size-4" />
</button>
</div>
);
}

View file

@ -78,6 +78,6 @@ describe("Layout", () => {
const navParent = navigation.parentElement;
// Should have transition and mobile/desktop behavior
expect(navParent).toHaveClass("fixed", "md:relative", "transition-all");
expect(navParent).toHaveClass("fixed", "md:relative", "transition-transform");
});
});

View file

@ -1,7 +1,7 @@
import { Button } from "@xtablo/ui/components/button";
import { MenuIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import { MenuIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { SideNavigation } from "./NavigationBar";
import { OnboardingModal } from "./OnboardingModal";
@ -12,6 +12,7 @@ const ONBOARDING_STORAGE_KEY = "xtablo-onboarding-completed";
export function Layout() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const location = useLocation();
useEffect(() => {
// Check if user has completed onboarding
@ -21,30 +22,64 @@ export function Layout() {
}
}, []);
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false);
}, [location.pathname]);
const handleOnboardingComplete = () => {
localStorage.setItem(ONBOARDING_STORAGE_KEY, "true");
setShowOnboarding(false);
};
const closeMobileMenu = useCallback(() => {
setIsMobileMenuOpen(false);
}, []);
return (
<div className="flex h-screen">
<OnboardingModal open={showOnboarding} onComplete={handleOnboardingComplete} />
{/* Mobile menu toggle button - 44px min touch target */}
<Button
variant="ghost"
size="icon"
className={twMerge(
"fixed z-50 md:hidden",
isMobileMenuOpen ? "top-2 left-55" : "top-2 left-4"
"fixed z-[60] md:hidden",
"min-w-[44px] min-h-[44px] w-11 h-11",
"top-2 left-2",
"safe-area-inset-left"
)}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
aria-expanded={isMobileMenuOpen}
>
<MenuIcon className="h-6 w-6" />
{isMobileMenuOpen ? (
<XIcon className="h-6 w-6" />
) : (
<MenuIcon className="h-6 w-6" />
)}
</Button>
{/* Mobile backdrop overlay */}
<div
className={twMerge(
"fixed md:relative transition-all duration-300 z-40",
"fixed inset-0 z-40 bg-black/50 md:hidden",
"transition-opacity duration-300 ease-in-out",
isMobileMenuOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
)}
onClick={closeMobileMenu}
aria-hidden="true"
/>
{/* Sidebar */}
<div
className={twMerge(
"fixed md:relative z-50 h-full",
"transition-transform duration-300 ease-in-out",
"md:transition-none",
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>

View file

@ -284,18 +284,25 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean }) => {
const isCollapsable = !isMobileMenuOpen;
const [isCollapsed, setIsCollapsed] = useState(!isCollapsable);
const [isCollapsed, setIsCollapsed] = useState(false);
// On mobile overlay, always show expanded (never collapsed)
const effectivelyCollapsed = isMobileMenuOpen ? false : isCollapsed;
return (
<nav
aria-label="Main navigation"
className={twMerge(
"group isolate flex flex-col overflow-y-auto overflow-x-hidden bg-navbar-background transition-all duration-300",
"fixed md:relative h-[calc(100vh-2rem)] md:h-screen z-50",
isCollapsed ? "w-16" : "w-48",
"h-full md:h-screen",
isMobileMenuOpen
? "w-40"
: effectivelyCollapsed
? "w-16"
: "w-48",
"md:flex",
"transform md:transform-none",
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
// On mobile in standalone mode, respect safe area insets
"pl-[env(safe-area-inset-left,0px)] pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]"
)}
>
<div className="relative flex flex-col items-center px-2 py-3 w-full">
@ -303,19 +310,19 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
to="/"
className={twMerge(
"flex flex-col items-center gap-2 w-full",
isCollapsed ? "justify-center" : ""
effectivelyCollapsed ? "justify-center" : ""
)}
aria-label="Home"
>
<img
src={getXtabloIcon()}
alt="Logo XTablo"
className={twMerge(isCollapsed ? "w-8 h-8" : "w-16 h-16", "rounded-lg")}
className={twMerge(effectivelyCollapsed ? "w-8 h-8" : "w-16 h-16", "rounded-lg")}
/>
<h1
className={twMerge(
"text-lg font-bold transition-all duration-300 text-gray-900 dark:text-white whitespace-nowrap",
isCollapsed ? "w-0 h-0 opacity-0" : "w-auto opacity-100"
effectivelyCollapsed ? "w-0 h-0 opacity-0" : "w-auto opacity-100"
)}
>
XTablo {isProd ? "" : isStaging ? "Staging" : "Dev"}
@ -326,10 +333,10 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(!isCollapsed)}
aria-label={isCollapsed ? "Expand navigation" : "Collapse navigation"}
aria-expanded={!isCollapsed}
aria-label={effectivelyCollapsed ? "Expand navigation" : "Collapse navigation"}
aria-expanded={!effectivelyCollapsed}
className={twMerge(
isCollapsed ? "relative" : "absolute top-2 right-2",
effectivelyCollapsed ? "relative" : "absolute top-2 right-2",
"size-5 p-1",
"text-gray-500 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white",
"transition-all duration-300",
@ -339,18 +346,18 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
"hover:scale-110"
)}
>
{isCollapsed ? <PlusIcon aria-hidden="true" /> : <MinusIcon aria-hidden="true" />}
{effectivelyCollapsed ? <PlusIcon aria-hidden="true" /> : <MinusIcon aria-hidden="true" />}
</Button>
)}
</div>
<MainNavigation isCollapsed={isCollapsed} />
<MainNavigation isCollapsed={effectivelyCollapsed} />
<div
className={twMerge(
"bg-navbar-background flex flex-col px-1 pb-1.5 w-full mt-auto gap-1",
isCollapsed ? "pl-2.5 pr-3.5" : ""
effectivelyCollapsed ? "pl-2.5 pr-3.5" : ""
)}
>
<UserMenuPopover isCollapsed={isCollapsed} />
<UserMenuPopover isCollapsed={effectivelyCollapsed} />
</div>
</nav>
);

View file

@ -41,7 +41,7 @@ export function ProjectCardList({
</button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{visibleTablos.map((tablo) => (
<ProjectCard
key={tablo.id}
@ -54,7 +54,7 @@ export function ProjectCardList({
{hasMore && (
<div className="flex justify-center mt-6">
<button
className="flex items-center gap-1.5 text-purple-600 hover:text-purple-500 dark:text-purple-400 dark:hover:text-purple-300 font-medium text-sm"
className="flex items-center gap-1.5 text-purple-600 hover:text-purple-500 dark:text-purple-400 dark:hover:text-purple-300 font-medium text-sm min-h-[44px] px-4"
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? (

View file

@ -188,10 +188,10 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div>
<TypographyH3 className="text-3xl font-bold text-foreground flex items-center gap-3">
<ListChecks className="w-8 h-8" />
<TypographyH3 className="text-2xl sm:text-3xl font-bold text-foreground flex items-center gap-3">
<ListChecks className="w-7 h-7 sm:w-8 sm:h-8" />
Tâches
</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
@ -221,7 +221,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
)}
{/* Kanban Board */}
<div className="bg-card rounded-lg border border-border p-6">
<div className="bg-card rounded-lg border border-border p-2 sm:p-4 lg:p-6">
<KanbanBoard
columns={columns}
members={members}

View file

@ -75,7 +75,7 @@ export const InlineTaskCreate = ({ status, members, etapes, onSubmit }: InlineTa
return (
<button
onClick={() => setIsCreating(true)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
className="w-full flex items-center gap-2 px-3 py-3 min-h-[44px] text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Ajouter une tâche
@ -188,11 +188,11 @@ export const InlineTaskCreate = ({ status, members, etapes, onSubmit }: InlineTa
variant="ghost"
size="sm"
onClick={handleCancel}
className="h-7 w-7 p-0"
className="h-9 w-9 min-h-[44px] min-w-[44px] p-0"
>
<X className="w-3 h-3" />
<X className="w-4 h-4" />
</Button>
<Button type="submit" size="sm" className="h-7 px-2 text-xs">
<Button type="submit" size="sm" className="h-9 px-3 min-h-[44px] text-xs">
Ajouter
</Button>
</div>

View file

@ -55,7 +55,7 @@ export const KanbanBoard = ({
};
return (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{columns.map((column) => (
<KanbanColumn
key={column.id}

View file

@ -54,7 +54,7 @@ export const KanbanColumn = ({
variant="ghost"
size="sm"
onClick={() => onAddTask(column.status)}
className="h-6 w-6 p-0"
className="h-8 w-8 min-h-[44px] min-w-[44px] p-0"
>
<Plus className="w-4 h-4" />
</Button>

View file

@ -142,8 +142,8 @@ export const TaskModal = ({
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-md shadow-xl">
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50">
<div className="bg-card border border-border rounded-t-2xl sm:rounded-lg p-4 sm:p-6 w-full sm:max-w-md shadow-xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<TypographyH2 className="text-xl font-bold text-foreground">
@ -151,7 +151,7 @@ export const TaskModal = ({
</TypographyH2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground hover:text-foreground transition-colors p-2 -m-2 min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<X className="w-5 h-5" />
</button>

View file

@ -7,6 +7,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { match } from "ts-pattern";
import { api } from "../lib/api";
import { clearOrgIdCookie } from "./organization";
import {
DEFAULT_SIGNUP_BILLING_INTENT,
PENDING_BILLING_CHECKOUT_PLAN_KEY,
@ -265,6 +266,7 @@ export function useLogout() {
mutationFn: async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
clearOrgIdCookie();
queryClient.removeQueries();
},
onSuccess: () => {

View file

@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "@xtablo/shared";
import { useEffect } from "react";
import { useAuthedApi } from "./auth";
export interface OrganizationSummary {
@ -8,6 +9,7 @@ export interface OrganizationSummary {
plan: string;
member_count: number;
tablo_count: number;
logo_url: string | null;
}
export interface OrganizationMember {
@ -51,16 +53,35 @@ export interface OrganizationInvite {
} | null;
}
function setOrgIdCookie(orgId: number): void {
document.cookie = `x-org-id=${orgId}; path=/; secure; samesite=lax; max-age=31536000`;
}
function clearOrgIdCookie(): void {
document.cookie = "x-org-id=; path=/; secure; samesite=lax; max-age=0";
}
export { clearOrgIdCookie };
export const useOrganization = () => {
const api = useAuthedApi();
return useQuery({
const query = useQuery({
queryKey: ["organization"],
queryFn: async () => {
const { data } = await api.get<OrganizationResponse>("/api/v1/users/organization");
return data;
},
});
// Set org ID cookie for dynamic manifest
useEffect(() => {
if (query.data?.organization?.id) {
setOrgIdCookie(query.data.organization.id);
}
}, [query.data?.organization?.id]);
return query;
};
export const useUpdateOrganization = () => {
@ -143,3 +164,72 @@ export const useRemoveOrganizationMember = () => {
},
});
};
export const useUploadOrgLogo = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const base64Content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result.split(",")[1]);
} else {
reject(new Error("Failed to read file"));
}
};
reader.onerror = () => reject(new Error("Error reading file"));
reader.readAsDataURL(file);
});
const { data } = await api.patch("/api/v1/users/organization", {
logo: { content: base64Content, contentType: file.type },
});
return data;
},
onSuccess: () => {
toast.add({
title: "Logo mis à jour",
description: "Le logo de l'organisation a bien été enregistré",
type: "success",
});
queryClient.invalidateQueries({ queryKey: ["organization"] });
},
onError: (error: Error) => {
toast.add({
title: "Erreur",
description: error.message || "Impossible de mettre à jour le logo",
type: "error",
});
},
});
};
export const useRemoveOrgLogo = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await api.patch("/api/v1/users/organization", { logo: null });
return data;
},
onSuccess: () => {
toast.add({
title: "Logo supprimé",
description: "Le logo de l'organisation a été supprimé",
type: "success",
});
queryClient.invalidateQueries({ queryKey: ["organization"] });
},
onError: (error: Error) => {
toast.add({
title: "Erreur",
description: error.message || "Impossible de supprimer le logo",
type: "error",
});
},
});
};

View file

@ -0,0 +1,60 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useInstallPrompt } from "./useInstallPrompt";
const DISMISSED_KEY = "pwa-install-dismissed";
describe("useInstallPrompt", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts with canInstall false and isStandalone false", () => {
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.canInstall).toBe(false);
expect(result.current.isStandalone).toBe(false);
expect(result.current.isDismissed).toBe(false);
});
it("captures beforeinstallprompt event and sets canInstall to true", () => {
const { result } = renderHook(() => useInstallPrompt());
const event = new Event("beforeinstallprompt");
Object.assign(event, { preventDefault: vi.fn(), prompt: vi.fn() });
act(() => {
window.dispatchEvent(event);
});
expect(result.current.canInstall).toBe(true);
});
it("dismiss persists to localStorage and sets isDismissed", () => {
const { result } = renderHook(() => useInstallPrompt());
act(() => {
result.current.dismiss();
});
expect(result.current.isDismissed).toBe(true);
expect(localStorage.getItem(DISMISSED_KEY)).toBe("true");
});
it("reads dismissed state from localStorage on mount", () => {
localStorage.setItem(DISMISSED_KEY, "true");
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.isDismissed).toBe(true);
});
it("detects iOS Safari", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
);
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.isIOS).toBe(true);
});
});

View file

@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef, useState } from "react";
const DISMISSED_KEY = "pwa-install-dismissed";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function useInstallPrompt() {
const deferredPrompt = useRef<BeforeInstallPromptEvent | null>(null);
const [canInstall, setCanInstall] = useState(false);
const [isDismissed, setIsDismissed] = useState(
() => localStorage.getItem(DISMISSED_KEY) === "true"
);
const isStandalone =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
(window.matchMedia("(display-mode: standalone)")?.matches ?? false);
const isIOS = typeof navigator !== "undefined" && /iPad|iPhone|iPod/.test(navigator.userAgent);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
deferredPrompt.current = e as BeforeInstallPromptEvent;
setCanInstall(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const promptInstall = useCallback(async () => {
if (!deferredPrompt.current) return;
const result = await deferredPrompt.current.prompt();
if (result.outcome === "accepted") {
deferredPrompt.current = null;
setCanInstall(false);
}
}, []);
const dismiss = useCallback(() => {
setIsDismissed(true);
localStorage.setItem(DISMISSED_KEY, "true");
}, []);
return { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss };
}

View file

@ -126,6 +126,14 @@
body {
@apply bg-background text-foreground;
}
@media (display-mode: standalone) {
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
}
.str-chat {

View file

@ -18,3 +18,8 @@ createRoot(document.getElementById("root")!).render(
</QueryClientProvider>
</StrictMode>
);
import { registerSW } from "virtual:pwa-register";
// Auto-update service worker — checks for updates on page load
registerSW({ immediate: true });

View file

@ -95,7 +95,7 @@ export function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden">
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden px-4 py-8 sm:px-6">
<AnimatedBackground />
<div
ref={cardRef}
@ -113,7 +113,7 @@ export function LoginPage() {
<div
className={twMerge(
"relative w-full h-full p-8 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
"relative w-full h-full p-5 sm:p-8 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
isHovered
? "shadow-[0_15px_35px_rgba(0,0,0,0.15)] dark:shadow-[0_15px_35px_rgba(0,0,0,0.3)]"
: "shadow-xl shadow-black/10 dark:shadow-black/25"
@ -161,7 +161,7 @@ export function LoginPage() {
/>
</div>
<h1 className="text-3xl font-bold text-foreground mb-8 text-center">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-6 sm:mb-8 text-center">
{t("auth:login.title")}
</h1>
@ -175,7 +175,7 @@ export function LoginPage() {
</div>
<div className="space-y-4 flex flex-col items-center">
<form className="space-y-4 w-95 max-w-md mx-auto" onSubmit={onSubmit}>
<form className="space-y-4 w-full max-w-md mx-auto" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="email">
{t("common:labels.email")} <span className="text-red-500">*</span>

View file

@ -34,6 +34,8 @@ import {
useOrganization,
useRemoveOrganizationMember,
useUpdateOrganization,
useUploadOrgLogo,
useRemoveOrgLogo,
} from "../hooks/organization";
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile";
import { useCookieConsent } from "../hooks/useCookieConsent";
@ -59,6 +61,9 @@ export default function SettingsPage() {
useInviteOrganizationUser();
const { mutate: removeOrganizationMember, isPending: removeOrganizationMemberPending } =
useRemoveOrganizationMember();
const { mutate: uploadOrgLogo, isPending: uploadOrgLogoPending } = useUploadOrgLogo();
const { mutate: removeOrgLogo, isPending: removeOrgLogoPending } = useRemoveOrgLogo();
const orgLogoInputRef = useRef<HTMLInputElement>(null);
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
@ -192,15 +197,52 @@ export default function SettingsPage() {
}
};
const handleOrgLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.add({
title: t("settings:toasts.error"),
description: t("settings:toasts.invalidImage"),
type: "error",
position: "top-center",
});
return;
}
// Validate minimum size client-side
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(img.src);
if (img.width < 512 || img.height < 512) {
toast.add({
title: t("settings:toasts.error"),
description: "L'image doit faire au moins 512x512 pixels",
type: "error",
position: "top-center",
});
return;
}
uploadOrgLogo(file);
};
img.src = URL.createObjectURL(file);
// Reset input to allow selecting same file again
if (orgLogoInputRef.current) {
orgLogoInputRef.current.value = "";
}
};
return (
<div className="min-h-screen bg-background">
<div className="container max-w-3xl mx-auto py-6 px-4">
<div className="flex justify-between items-start mb-4">
<div className="flex flex-wrap justify-between items-start gap-2 mb-4">
<div>
<TypographyH3>{t("settings:title")}</TypographyH3>
<TypographyMuted>{t("settings:subtitle")}</TypographyMuted>
</div>
<div className="flex items-center gap-2 mt-2">
<div className="flex flex-wrap items-center gap-2 mt-2">
{organizationData?.active_subscription_plan === "annual" && (
<Badge className="bg-linear-to-r from-purple-500 to-blue-500 text-white border-transparent text-xs">
Founder
@ -223,9 +265,9 @@ export default function SettingsPage() {
<CardDescription>{t("settings:avatar.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start gap-6">
<div className="flex flex-col sm:flex-row items-start gap-6">
{/* Avatar Preview */}
<div className="relative group">
<div className="relative group shrink-0">
<Avatar className="w-32 h-32 ring-4 ring-gray-100 dark:ring-gray-800">
<AvatarImage src={avatarPreview || undefined} alt="Avatar" />
<AvatarFallback className="text-3xl bg-linear-to-br from-purple-500 to-blue-500 text-white">
@ -266,7 +308,7 @@ export default function SettingsPage() {
hidden
/>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
@ -372,6 +414,68 @@ export default function SettingsPage() {
<CardDescription>{t("settings:organization.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Organization Logo */}
<div className="space-y-2">
<Label>{t("settings:organization.logo", "Logo de l'organisation")}</Label>
<div className="flex flex-wrap items-center gap-4">
{organizationData?.organization?.logo_url ? (
<img
src={`https://assets.xtablo.com/org-icons/${organizationData.organization.id}/icon-192.png`}
alt="Organization logo"
className="w-16 h-16 rounded-lg object-cover ring-2 ring-gray-100 dark:ring-gray-800 shrink-0"
/>
) : (
<div className="w-16 h-16 rounded-lg bg-muted flex items-center justify-center ring-2 ring-gray-100 dark:ring-gray-800 shrink-0">
<UploadIcon className="w-6 h-6 text-muted-foreground" />
</div>
)}
<div className="flex flex-wrap gap-2">
<Input
ref={orgLogoInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleOrgLogoChange}
hidden
/>
<Button
variant="outline"
size="sm"
disabled={uploadOrgLogoPending}
onClick={() => orgLogoInputRef.current?.click()}
className="gap-2"
>
{uploadOrgLogoPending ? (
<Loader2Icon className="w-4 h-4 animate-spin" />
) : (
<UploadIcon className="w-4 h-4" />
)}
{organizationData?.organization?.logo_url
? t("settings:organization.changeLogo", "Changer")
: t("settings:organization.uploadLogo", "Uploader")}
</Button>
{organizationData?.organization?.logo_url && (
<Button
variant="outline"
size="sm"
disabled={removeOrgLogoPending}
onClick={() => removeOrgLogo()}
className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
>
{removeOrgLogoPending ? (
<Loader2Icon className="w-4 h-4 animate-spin" />
) : (
<Trash2Icon className="w-4 h-4" />
)}
{t("settings:organization.removeLogo", "Supprimer")}
</Button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("settings:organization.logoHint", "PNG, JPEG ou WebP, minimum 512x512 pixels")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="organizationName">{t("settings:organization.name")}</Label>
<Input
@ -469,7 +573,7 @@ export default function SettingsPage() {
return (
<div
key={invite.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between rounded-md border px-3 py-2 gap-1"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
@ -477,7 +581,7 @@ export default function SettingsPage() {
{member?.email || invite.invited_email}
</p>
</div>
<p className="text-xs text-muted-foreground shrink-0 ml-3">
<p className="text-xs text-muted-foreground shrink-0 sm:ml-3">
{t("settings:teamInvite.invitedOn", {
date: formatDate(invite.created_at),
})}
@ -500,7 +604,7 @@ export default function SettingsPage() {
{organizationMembers.map((member) => (
<div
key={member.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
className="flex flex-col sm:flex-row sm:items-center sm:justify-between rounded-md border px-3 py-2 gap-2"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
@ -513,7 +617,7 @@ export default function SettingsPage() {
</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
<div className="flex items-center gap-2 ml-3">
<div className="flex flex-wrap items-center gap-2 sm:ml-3">
<p className="text-xs text-muted-foreground shrink-0">
{t("settings:teamInvite.joinedOn", {
date: member.created_at

View file

@ -151,7 +151,7 @@ export function SignUpPage() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden">
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden px-4 py-8 sm:px-6">
<AnimatedBackground />
<div
ref={cardRef}
@ -168,7 +168,7 @@ export function SignUpPage() {
<div
className={twMerge(
"relative w-full h-full p-6 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
"relative w-full h-full p-4 sm:p-6 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
isHovered
? "shadow-[0_15px_35px_rgba(0,0,0,0.15)] dark:shadow-[0_15px_35px_rgba(0,0,0,0.3)]"
: "shadow-xl shadow-black/10 dark:shadow-black/25"
@ -216,13 +216,13 @@ export function SignUpPage() {
/>
</div>
<h1 className="text-2xl font-bold text-foreground mb-6 text-center">
<h1 className="text-xl sm:text-2xl font-bold text-foreground mb-5 sm:mb-6 text-center">
{t("auth:signup.title")}
</h1>
<div className="space-y-3 flex flex-col items-center">
<form className="space-y-3 w-full" onSubmit={onSubmit}>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="first_name" className="text-sm">
{t("auth:signup.firstName")} <span className="text-red-500">*</span>

View file

@ -399,10 +399,10 @@ export const TabloDetailsPage = () => {
<h1 className="text-xl md:text-3xl font-bold text-foreground">{tablo.name}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3 w-full sm:w-auto">
<Link
to={`/chat/${tabloId}`}
className="bg-[#804EEC] hover:bg-[#6f3fd4] text-white font-medium py-2 px-4 rounded-lg flex items-center gap-2 transition-colors"
className="bg-[#804EEC] hover:bg-[#6f3fd4] text-white font-medium py-2.5 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors flex-1 sm:flex-none min-h-[44px]"
>
<MessageCircleIcon className="w-5 h-5" />
Discussion
@ -411,7 +411,7 @@ export const TabloDetailsPage = () => {
<button
type="button"
onClick={() => setIsShareDialogOpen(true)}
className="border border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10 font-medium py-2 px-4 rounded-lg flex items-center gap-2 transition-colors"
className="border border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10 font-medium py-2.5 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors flex-1 sm:flex-none min-h-[44px]"
>
<UserPlusIcon className="w-5 h-5" />
Inviter
@ -421,12 +421,12 @@ export const TabloDetailsPage = () => {
</div>
{/* ── Metadata bar ──────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-6 text-sm border-b border-[#F2F4F7] dark:border-gray-700 pb-4 mb-4">
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<div className="flex flex-wrap items-center gap-3 sm:gap-6 text-sm border-b border-[#F2F4F7] dark:border-gray-700 pb-4 mb-4">
<div className="flex items-center gap-2 sm:border-r border-[#D0D5DD] dark:border-gray-600 sm:pr-4">
<span className="text-muted-foreground">Rôle :</span>
<span className="text-foreground font-medium">{isAdmin ? "Admin" : "Invité"}</span>
</div>
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<div className="flex items-center gap-2 sm:border-r border-[#D0D5DD] dark:border-gray-600 sm:pr-4">
<span className="text-muted-foreground">Créé le :</span>
<span className="text-foreground">
{new Intl.DateTimeFormat("fr-FR", {
@ -436,7 +436,7 @@ export const TabloDetailsPage = () => {
}).format(new Date(tablo.created_at))}
</span>
</div>
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
<div className="flex items-center gap-2 sm:border-r border-[#D0D5DD] dark:border-gray-600 sm:pr-4">
<span className="text-muted-foreground">Statut :</span>
<span className={cn("px-3 py-1 rounded-full text-xs font-medium", badgeClass)}>
{statusLabel}
@ -462,7 +462,7 @@ export const TabloDetailsPage = () => {
{/* ── Tab navigation ──────────────────────────────────────────────── */}
<div className="w-full bg-white dark:bg-background sticky top-0 z-40">
<div className="px-4 py-2">
<div className="flex flex-wrap items-center gap-6 mb-4 border-b border-[#F2F4F7] dark:border-gray-700">
<div className="flex items-center gap-4 sm:gap-6 mb-4 border-b border-[#F2F4F7] dark:border-gray-700 overflow-x-auto scrollbar-none -mx-4 px-4">
{TABS.map((tab) => {
const isActive = activeSection === tab.id;
return (
@ -472,7 +472,7 @@ export const TabloDetailsPage = () => {
disabled={tab.disabled}
onClick={() => !tab.disabled && setSearchParams({ section: tab.id })}
className={cn(
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2 shrink-0 min-h-[44px]",
isActive
? "text-[#804EEC] border-[#804EEC]"
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
@ -824,13 +824,13 @@ export const TabloDetailsPage = () => {
<div className="space-y-4">
{/* Invite Input */}
<div className="flex space-x-2">
<div className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur"
className="flex-1"
className="flex-1 min-h-[44px]"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center px-4">
@ -1027,7 +1027,7 @@ function EtapesSection({
return (
<div className="space-y-4">
{isAdmin && (
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Input
value={newEtapeTitle}
onChange={(event) => setNewEtapeTitle(event.target.value)}
@ -1037,11 +1037,12 @@ function EtapesSection({
void handleAddEtape();
}
}}
className="h-9 sm:w-80"
className="h-11 sm:h-9 sm:w-80"
/>
<Button
onClick={() => void handleAddEtape()}
disabled={isCreatingEtape || !newEtapeTitle.trim()}
className="min-h-[44px] sm:min-h-0"
>
<PlusIcon className="w-4 h-4" />
Ajouter une étape
@ -1085,7 +1086,7 @@ function EtapesSection({
<button
type="button"
onClick={() => toggleEtape(etape.id)}
className="w-full flex items-center gap-4 px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
className="w-full flex items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left min-h-[56px]"
>
{isExpanded ? (
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
@ -1100,58 +1101,60 @@ function EtapesSection({
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
{etape.title}
</h3>
{etape.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
{etape.description}
</p>
)}
</div>
{etape.due_date && (
<div
<div className="flex items-center gap-2 shrink-0">
{etape.due_date && (
<div
className={cn(
"items-center gap-1 text-xs hidden sm:flex",
derivedStatus !== "done" &&
new Date(etape.due_date) < new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
)}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(etape.due_date))}
</span>
</div>
)}
<span
className={cn(
"flex items-center gap-1 text-xs shrink-0",
derivedStatus !== "done" &&
new Date(etape.due_date) < new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
"px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-xs font-medium",
status.color
)}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(etape.due_date))}
</span>
</div>
)}
{status.label}
</span>
<span
className={cn(
"px-2.5 py-1 rounded-full text-xs font-medium shrink-0",
status.color
)}
>
{status.label}
</span>
{totalCount > 0 && (
<div className="flex items-center gap-2 shrink-0">
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${progressPct}%` }}
/>
{totalCount > 0 && (
<div className="hidden sm:flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{doneCount}/{totalCount}
</span>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{doneCount}/{totalCount}
</span>
</div>
)}
)}
</div>
</button>
{/* Child tasks + add task */}
@ -1162,7 +1165,7 @@ function EtapesSection({
{childTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 px-5 py-3 pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
className="flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{task.status === "done" ? (
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
@ -1214,14 +1217,14 @@ function EtapesSection({
)}
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
<div className="px-5 py-4 pl-16 text-sm text-muted-foreground">
<div className="px-3 sm:px-5 py-4 pl-8 sm:pl-16 text-sm text-muted-foreground">
Aucune tâche dans cette étape
</div>
)}
{/* Inline add task */}
{addingTaskToEtape === etape.id ? (
<div className="flex items-center gap-2 px-5 py-3 pl-16 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 border-t border-gray-100 dark:border-gray-700">
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
<input
autoFocus
@ -1236,13 +1239,13 @@ function EtapesSection({
}
}}
placeholder="Nom de la tâche..."
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0"
/>
<button
type="button"
onClick={() => handleAddTask(etape.id)}
disabled={!newTaskTitle.trim()}
className="text-xs font-medium px-3 py-1 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors"
className="text-xs font-medium px-3 py-2 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors min-h-[36px] shrink-0"
>
Ajouter
</button>
@ -1252,7 +1255,7 @@ function EtapesSection({
setAddingTaskToEtape(null);
setNewTaskTitle("");
}}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
className="text-xs text-muted-foreground hover:text-foreground px-2 py-2 min-h-[36px] shrink-0"
>
Annuler
</button>
@ -1265,7 +1268,7 @@ function EtapesSection({
setAddingTaskToEtape(etape.id);
setNewTaskTitle("");
}}
className="flex items-center gap-2 px-5 py-2.5 pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700"
className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700 min-h-[44px]"
>
<PlusIcon className="w-4 h-4" />
Ajouter une tâche

View file

@ -381,7 +381,7 @@ export const TabloPage = () => {
}}
>
<div
className={`bg-card rounded-lg shadow-lg transition-all duration-300 w-56 overflow-hidden border border-border ${
className={`bg-card rounded-lg shadow-lg transition-all duration-300 w-full overflow-hidden border border-border ${
isAdmin ? "hover:shadow-xl cursor-pointer" : "hover:shadow-xl cursor-pointer opacity-75"
}`}
onClick={(e) => {
@ -430,11 +430,11 @@ export const TabloPage = () => {
<span>{getUserRole(tablo)}</span>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 pt-1">
<div className="flex items-center gap-2 pt-1">
<Button
variant="outline"
size="icon-sm"
className="p-1.5"
className="min-w-[44px] min-h-[44px] p-2"
onClick={(e) => {
e.stopPropagation();
navigate(`/chat/${tablo.id}`);
@ -446,7 +446,7 @@ export const TabloPage = () => {
<Button
variant="outline"
size="icon-sm"
className="p-1.5"
className="min-w-[44px] min-h-[44px] p-2"
onClick={(e) => {
e.stopPropagation();
navigate(`/tablos/${tablo.id}?section=members`);
@ -458,7 +458,7 @@ export const TabloPage = () => {
<Button
variant="outline"
size="icon-sm"
className="p-1.5 text-destructive hover:text-destructive"
className="min-w-[44px] min-h-[44px] p-2 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteTablo(tablo.id);
@ -509,12 +509,12 @@ export const TabloPage = () => {
setContextMenuPosition(null);
}}
>
<header className="px-6 pt-6 pb-4">
<p className="text-base text-[#475467] dark:text-gray-400 mb-2 font-medium">
<header className="px-4 sm:px-6 pt-6 pb-4">
<p className="text-sm sm:text-base text-[#475467] dark:text-gray-400 mb-2 font-medium">
{formattedDate}
</p>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-[24px] font-medium text-[#475467] dark:text-gray-400">
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-xl sm:text-[24px] font-medium text-[#475467] dark:text-gray-400">
{getGreeting()},{" "}
<span className="text-gray-900 dark:text-gray-100 font-medium">
{user.first_name ?? user.name}
@ -656,10 +656,10 @@ export const TabloPage = () => {
onSendMessage={() => navigate("/chat")}
/>
<div className="container mx-auto px-4 py-8">
<div className="py-8">
{filteredTablos && filteredTablos.length > 0 ? (
viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredTablos.map((tablo) => renderTablo(tablo))}
</div>
) : (

View file

@ -130,7 +130,7 @@ function TabloCard({
</span>
<button
type="button"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors p-2 -m-2 min-w-[44px] min-h-[44px] flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
onDelete(tablo.id);
@ -262,7 +262,7 @@ function TabloRow({
<td className="px-6 py-4 text-right">
<button
type="button"
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors p-1 rounded"
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors p-2 rounded min-w-[44px] min-h-[44px] inline-flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
onDelete(tablo.id);
@ -317,7 +317,7 @@ export function TablosPage() {
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-colors"
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-colors w-full sm:w-auto min-h-[44px]"
>
<PlusIcon className="w-5 h-5" />
{t("tablo.createButton")}
@ -397,7 +397,7 @@ export function TablosPage() {
</p>
</div>
) : viewMode === "card" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
{filteredTablos.map((tablo) => (
<TabloCard
key={tablo.id}
@ -408,8 +408,8 @@ export function TablosPage() {
))}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-hidden">
<table className="w-full">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full min-w-[600px]">
<thead className="bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">

View file

@ -314,7 +314,7 @@ export function TasksPage() {
!tab.disabled && setViewMode(tab.id as "kanban" | "aggregated" | "roadmap")
}
className={twMerge(
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
"flex items-center gap-2 pb-3 pt-1 px-2 text-sm font-semibold transition-colors border-b-2 min-h-[44px]",
isActive
? "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400"
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
@ -475,7 +475,7 @@ export function TasksPage() {
<button
type="button"
onClick={() => openTaskModal()}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-1 transition-colors"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"
>
<PlusIcon className="w-[18px] h-[18px]" />
</button>
@ -523,7 +523,7 @@ export function TasksPage() {
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 mt-0.5"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 p-2 -m-1 min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
@ -690,7 +690,7 @@ export function TasksPage() {
<button
type="button"
onClick={() => openTaskModal()}
className="inline-flex items-center gap-1 h-8 px-3 text-xs font-medium rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
className="inline-flex items-center justify-center gap-1 h-8 px-3 min-h-[44px] min-w-[44px] text-xs font-medium rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<PlusIcon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
@ -845,7 +845,7 @@ export function TasksPage() {
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="inline-flex items-center justify-center h-8 w-8 min-h-[44px] min-w-[44px] rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<EllipsisVerticalIcon className="w-4 h-4 text-gray-400" />
</button>

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite-plugin-pwa/client"],
"module": "ESNext",
"skipLibCheck": true,

View file

@ -5,6 +5,7 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig, type PluginOption } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
@ -16,6 +17,25 @@ export default defineConfig(({ mode }) => {
tsconfigPaths(),
];
plugins.push(
VitePWA({
registerType: "autoUpdate",
injectRegister: false,
includeAssets: [
"icon.jpg",
"logo_dark.png",
"logo_white.png",
],
manifest: false,
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,jpg,svg,woff,woff2}"],
globIgnores: ["**/*.map"],
navigateFallback: "index.html",
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
})
);
// Only include cloudflare plugin when not in test mode
if (mode !== "test" && process.env.VITEST !== "true") {
plugins.push(cloudflare({ inspectorPort: 9230 }));

View file

@ -1,4 +1,23 @@
export declare function parseOrgIdFromCookie(cookieHeader: string | null): number | null;
interface ManifestIcon {
src: string;
sizes: string;
type: string;
purpose?: string;
}
interface WebAppManifest {
name: string;
short_name: string;
description: string;
start_url: string;
display: string;
orientation: string;
theme_color: string;
background_color: string;
icons: ManifestIcon[];
}
export declare function buildManifest(orgId: number | null): WebAppManifest;
declare const _default: {
fetch(request: any): Response;
fetch(request: Request): Response;
};
export default _default;

1
apps/main/worker/index.test.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { buildManifest, parseOrgIdFromCookie } from "./index";
import worker from "./index";
describe("parseOrgIdFromCookie", () => {
it("returns null when no cookie header", () => {
expect(parseOrgIdFromCookie(null)).toBeNull();
});
it("returns null when x-org-id cookie is missing", () => {
expect(parseOrgIdFromCookie("other=value; foo=bar")).toBeNull();
});
it("parses x-org-id from cookie string", () => {
expect(parseOrgIdFromCookie("x-org-id=42; other=value")).toBe(42);
});
it("returns null for non-numeric x-org-id", () => {
expect(parseOrgIdFromCookie("x-org-id=abc")).toBeNull();
});
});
describe("buildManifest", () => {
it("returns default icons when orgId is null", () => {
const manifest = buildManifest(null);
expect(manifest.name).toBe("XTablo");
expect(manifest.icons[0].src).toBe("/pwa-icons/pwa-192x192.png");
});
it("returns org-specific icon URLs from assets.xtablo.com when orgId is provided", () => {
const manifest = buildManifest(42);
expect(manifest.name).toBe("XTablo");
expect(manifest.icons[0].src).toBe("https://assets.xtablo.com/org-icons/42/icon-192.png");
expect(manifest.icons[1].src).toBe("https://assets.xtablo.com/org-icons/42/icon-512.png");
expect(manifest.icons[2].src).toBe("https://assets.xtablo.com/org-icons/42/icon-512-maskable.png");
expect(manifest.icons[2].purpose).toBe("maskable");
});
});
describe("worker icon redirects", () => {
it("redirects apple-touch-icon to org-specific icon when cookie is set", async () => {
const request = new Request("https://app.xtablo.com/pwa-icons/apple-touch-icon-180x180.png", {
headers: { cookie: "x-org-id=42" },
});
const response = await worker.fetch(request);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("https://assets.xtablo.com/org-icons/42/icon-180.png");
});
it("redirects apple-touch-icon to default when no cookie", async () => {
const request = new Request("https://app.xtablo.com/pwa-icons/apple-touch-icon-180x180.png");
const response = await worker.fetch(request);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("https://app.xtablo.com/pwa-icons/default-apple-touch-icon-180x180.png");
});
it("redirects favicon to org-specific icon when cookie is set", async () => {
const request = new Request("https://app.xtablo.com/pwa-icons/favicon-32x32.png", {
headers: { cookie: "x-org-id=7" },
});
const response = await worker.fetch(request);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("https://assets.xtablo.com/org-icons/7/icon-32.png");
});
});

View file

@ -1,16 +1,104 @@
// @ts-nocheck
// biome-ignore-file: Worker entry point with dynamic imports
/* eslint-disable */
export function parseOrgIdFromCookie(cookieHeader: string | null): number | null {
if (!cookieHeader) return null;
const match = cookieHeader.match(/(?:^|;\s*)x-org-id=(\d+)/);
if (!match) return null;
const id = Number.parseInt(match[1], 10);
return Number.isNaN(id) ? null : id;
}
interface ManifestIcon {
src: string;
sizes: string;
type: string;
purpose?: string;
}
interface WebAppManifest {
name: string;
short_name: string;
description: string;
start_url: string;
display: string;
orientation: string;
theme_color: string;
background_color: string;
icons: ManifestIcon[];
}
const ASSETS_BASE_URL = "https://assets.xtablo.com";
export function buildManifest(orgId: number | null): WebAppManifest {
const base = {
name: "XTablo",
short_name: "XTablo",
description: "Collaborative project management for construction teams",
start_url: "/",
display: "standalone",
orientation: "any",
theme_color: "#1e1b2e",
background_color: "#1e1b2e",
};
if (orgId !== null) {
return {
...base,
icons: [
{ src: `${ASSETS_BASE_URL}/org-icons/${orgId}/icon-192.png`, sizes: "192x192", type: "image/png" },
{ src: `${ASSETS_BASE_URL}/org-icons/${orgId}/icon-512.png`, sizes: "512x512", type: "image/png" },
{ src: `${ASSETS_BASE_URL}/org-icons/${orgId}/icon-512-maskable.png`, sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
return {
...base,
icons: [
{ src: "/pwa-icons/pwa-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/pwa-icons/pwa-512x512.png", sizes: "512x512", type: "image/png" },
{ src: "/pwa-icons/pwa-512x512-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
export default {
fetch(request) {
fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return Response.json({
name: "Cloudflare",
// Serve org-specific or default PWA icons
// Static files are renamed to default-* so the worker handles all requests for these paths
const iconMap: Record<string, { orgFile: string; defaultFile: string }> = {
"/pwa-icons/apple-touch-icon-180x180.png": { orgFile: "icon-180.png", defaultFile: "/pwa-icons/default-apple-touch-icon-180x180.png" },
"/pwa-icons/favicon-32x32.png": { orgFile: "icon-32.png", defaultFile: "/pwa-icons/default-favicon-32x32.png" },
"/pwa-icons/favicon-16x16.png": { orgFile: "icon-16.png", defaultFile: "/pwa-icons/default-favicon-16x16.png" },
};
if (iconMap[url.pathname]) {
const cookieHeader = request.headers.get("cookie");
const orgId = parseOrgIdFromCookie(cookieHeader);
const entry = iconMap[url.pathname];
if (orgId !== null) {
return Response.redirect(`${ASSETS_BASE_URL}/org-icons/${orgId}/${entry.orgFile}`, 302);
}
// No cookie — serve default icon
return Response.redirect(`${url.origin}${entry.defaultFile}`, 302);
}
if (url.pathname === "/manifest.webmanifest") {
const cookieHeader = request.headers.get("cookie");
const orgId = parseOrgIdFromCookie(cookieHeader);
const manifest = buildManifest(orgId);
return new Response(JSON.stringify(manifest), {
headers: {
"Content-Type": "application/manifest+json",
"Cache-Control": "no-cache",
},
});
}
if (url.pathname.startsWith("/api/")) {
return Response.json({ name: "Cloudflare" });
}
return new Response(null, { status: 404 });
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,891 @@
# PWA Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make apps/main installable as a native-feeling PWA with fast repeat loads and mobile polish.
**Architecture:** Add `vite-plugin-pwa` to the existing Vite build. The plugin auto-generates a service worker (Workbox) and web app manifest. A custom install prompt hook + banner component handles the "Add to Home Screen" UX. Safe area CSS and viewport meta tags polish the standalone experience.
**Tech Stack:** vite-plugin-pwa, Workbox (auto-generated), sharp (icon generation), React hooks
---
## File Map
| Action | File | Responsibility |
|--------|------|---------------|
| Create | `apps/main/scripts/generate-pwa-icons.ts` | One-time script to generate PWA icon set from source images |
| Create | `apps/main/public/pwa-icons/` | Generated icon files (committed to repo) |
| Create | `apps/main/src/hooks/useInstallPrompt.ts` | React hook capturing `beforeinstallprompt` event |
| Create | `apps/main/src/hooks/useInstallPrompt.test.ts` | Tests for install prompt hook |
| Create | `apps/main/src/components/InstallBanner.tsx` | Dismissible install prompt banner |
| Create | `apps/main/src/components/InstallBanner.test.tsx` | Tests for install banner |
| Modify | `apps/main/package.json` | Add `vite-plugin-pwa` and `sharp` dependencies |
| Modify | `apps/main/vite.config.ts` | Add VitePWA plugin config |
| Modify | `apps/main/index.html` | Add PWA meta tags, update viewport |
| Modify | `apps/main/src/main.tsx` | Register service worker |
| Modify | `apps/main/src/main.css` | Add safe area CSS for standalone mode |
| Modify | `apps/main/src/App.tsx` | Add InstallBanner to app shell |
---
### Task 1: Install dependencies
**Files:**
- Modify: `apps/main/package.json`
- [ ] **Step 1: Install vite-plugin-pwa**
```bash
cd apps/main && pnpm add -D vite-plugin-pwa
```
- [ ] **Step 2: Install sharp for icon generation**
```bash
cd apps/main && pnpm add -D sharp @types/sharp
```
- [ ] **Step 3: Verify installation**
```bash
cd apps/main && pnpm list vite-plugin-pwa sharp
```
Expected: Both packages listed with versions.
- [ ] **Step 4: Commit**
```bash
git add apps/main/package.json pnpm-lock.yaml
git commit -m "feat(pwa): add vite-plugin-pwa and sharp dependencies"
```
---
### Task 2: Generate PWA icon set
**Files:**
- Create: `apps/main/scripts/generate-pwa-icons.ts`
- Create: `apps/main/public/pwa-icons/*.png`
- [ ] **Step 1: Create the icon generation script**
Create `apps/main/scripts/generate-pwa-icons.ts`:
```typescript
import sharp from "sharp";
import path from "node:path";
import fs from "node:fs";
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
const OUTPUT_DIR = path.join(PUBLIC_DIR, "pwa-icons");
const ICON_SIZES = [
{ size: 16, name: "favicon-16x16.png" },
{ size: 32, name: "favicon-32x32.png" },
{ size: 180, name: "apple-touch-icon-180x180.png" },
{ size: 192, name: "pwa-192x192.png" },
{ size: 512, name: "pwa-512x512.png" },
{ size: 512, name: "pwa-512x512-maskable.png", maskable: true },
];
// Maskable icons need 10% safe zone padding (per spec).
// We add a colored background and shrink the source to 80% to stay within the safe zone.
const MASKABLE_PADDING_RATIO = 0.1;
const MASKABLE_BG_COLOR = "#ffffff";
async function generateIcons(sourceFile: string) {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
for (const icon of ICON_SIZES) {
const outputPath = path.join(OUTPUT_DIR, icon.name);
if (icon.maskable) {
// Create maskable icon with padding and background
const innerSize = Math.round(icon.size * (1 - MASKABLE_PADDING_RATIO * 2));
const offset = Math.round(icon.size * MASKABLE_PADDING_RATIO);
const resizedIcon = await sharp(sourceFile)
.resize(innerSize, innerSize, { fit: "contain" })
.toBuffer();
await sharp({
create: {
width: icon.size,
height: icon.size,
channels: 4,
background: MASKABLE_BG_COLOR,
},
})
.composite([{ input: resizedIcon, left: offset, top: offset }])
.png()
.toFile(outputPath);
} else {
await sharp(sourceFile)
.resize(icon.size, icon.size, { fit: "contain" })
.png()
.toFile(outputPath);
}
console.log(`Generated: ${icon.name} (${icon.size}x${icon.size})`);
}
}
// Determine source: pass "staging" as first arg for staging icons
const variant = process.argv[2];
const sourceFile =
variant === "staging"
? path.join(PUBLIC_DIR, "staging_icon.jpg")
: path.join(PUBLIC_DIR, "icon.jpg");
if (!fs.existsSync(sourceFile)) {
console.error(`Source icon not found: ${sourceFile}`);
process.exit(1);
}
console.log(`Generating PWA icons from: ${sourceFile}`);
generateIcons(sourceFile).then(() => console.log("Done!"));
```
- [ ] **Step 2: Run the script for production icons**
```bash
cd apps/main && npx tsx scripts/generate-pwa-icons.ts
```
Expected: 6 PNG files created in `apps/main/public/pwa-icons/`.
- [ ] **Step 3: Verify the generated icons exist and have reasonable sizes**
```bash
ls -la apps/main/public/pwa-icons/
```
Expected: 6 files — favicon-16x16.png, favicon-32x32.png, apple-touch-icon-180x180.png, pwa-192x192.png, pwa-512x512.png, pwa-512x512-maskable.png.
- [ ] **Step 4: Commit**
```bash
git add apps/main/scripts/generate-pwa-icons.ts apps/main/public/pwa-icons/
git commit -m "feat(pwa): add icon generation script and generated PWA icons"
```
---
### Task 3: Configure vite-plugin-pwa
**Files:**
- Modify: `apps/main/vite.config.ts`
- [ ] **Step 1: Add VitePWA plugin to vite config**
In `apps/main/vite.config.ts`, add the import at the top:
```typescript
import { VitePWA } from "vite-plugin-pwa";
```
Then add the plugin to the `plugins` array (before the cloudflare conditional). Note that `mode` is already available from the `defineConfig` callback parameter:
```typescript
VitePWA({
registerType: "autoUpdate",
includeAssets: [
"public/icon.jpg",
"public/logo_dark.png",
"public/logo_white.png",
],
manifest: {
name: mode === "staging" ? "XTablo (Staging)" : "XTablo",
short_name: "XTablo",
description: "Collaborative project management for construction teams",
start_url: "/",
display: "standalone",
orientation: "any",
theme_color: "#1e1b2e",
background_color: "#1e1b2e",
icons: [
{
src: "pwa-icons/pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-icons/pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "pwa-icons/pwa-512x512-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,jpg,svg,woff,woff2}"],
// Do not precache source maps
globIgnores: ["**/*.map"],
},
}),
```
Note: `theme_color` and `background_color` are set to `#1e1b2e` which is the dark navbar background color from the CSS. This provides a consistent splash screen feel on Android. Pick dark because standalone apps typically launch in the user's last-used theme, and dark provides a cleaner splash.
- [ ] **Step 2: Build to verify the plugin works**
```bash
cd apps/main && pnpm build
```
Expected: Build succeeds. The `dist/` folder should now contain `manifest.webmanifest`, `sw.js`, and `workbox-*.js`.
- [ ] **Step 3: Verify manifest was generated**
```bash
cat apps/main/dist/manifest.webmanifest | head -30
```
Expected: JSON with name "XTablo", icons array, display "standalone".
- [ ] **Step 4: Commit**
```bash
git add apps/main/vite.config.ts
git commit -m "feat(pwa): configure vite-plugin-pwa with manifest and workbox precaching"
```
---
### Task 4: Update HTML meta tags and viewport
**Files:**
- Modify: `apps/main/index.html`
- [ ] **Step 1: Update index.html with PWA meta tags**
Replace the current `<head>` content in `apps/main/index.html`:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/pwa-icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/pwa-icons/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/pwa-icons/apple-touch-icon-180x180.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1e1b2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>XTablo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
Changes from original:
- Favicon now points to generated PNG icons instead of `icon.jpg`
- Added `viewport-fit=cover` for notched devices
- Added `theme-color` meta tag
- Added Apple PWA meta tags
- The `<link rel="manifest">` tag is injected automatically by vite-plugin-pwa — do NOT add it manually
- [ ] **Step 2: Build and verify meta tags in output**
```bash
cd apps/main && pnpm build && head -20 dist/index.html
```
Expected: The built HTML includes all meta tags plus an auto-injected `<link rel="manifest">`.
- [ ] **Step 3: Commit**
```bash
git add apps/main/index.html
git commit -m "feat(pwa): add PWA meta tags and update viewport for standalone mode"
```
---
### Task 5: Register service worker in main.tsx
**Files:**
- Modify: `apps/main/src/main.tsx`
- [ ] **Step 1: Add SW registration**
Add the following import and call at the end of `apps/main/src/main.tsx`, after the `createRoot().render()` call:
```typescript
import { registerSW } from "virtual:pwa-register";
// Auto-update service worker — checks for updates on page load
registerSW({ immediate: true });
```
- [ ] **Step 2: Add type declaration for the virtual module**
The `virtual:pwa-register` module needs a type declaration. `vite-plugin-pwa` ships its own types. Add to `apps/main/tsconfig.json` (or `tsconfig.app.json`, whichever controls the app source) in `compilerOptions.types`:
```json
"types": ["vite-plugin-pwa/client"]
```
If there's no `types` array yet, check the existing tsconfig structure and add it appropriately.
- [ ] **Step 3: Verify typecheck passes**
```bash
cd apps/main && pnpm typecheck
```
Expected: No errors related to `virtual:pwa-register`.
- [ ] **Step 4: Commit**
```bash
git add apps/main/src/main.tsx apps/main/tsconfig.json apps/main/tsconfig.app.json
git commit -m "feat(pwa): register service worker with auto-update"
```
---
### Task 6: Add safe area CSS for standalone mode
**Files:**
- Modify: `apps/main/src/main.css`
- [ ] **Step 1: Add safe area styles**
Add the following at the end of the `@layer base` block in `apps/main/src/main.css` (after the existing `body` rule, still inside `@layer base`):
```css
@media (display-mode: standalone) {
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
```
This only applies when the app is running in standalone (installed PWA) mode, not in a regular browser tab.
- [ ] **Step 2: Build to verify no CSS errors**
```bash
cd apps/main && pnpm build
```
Expected: Build succeeds with no CSS errors.
- [ ] **Step 3: Commit**
```bash
git add apps/main/src/main.css
git commit -m "feat(pwa): add safe area insets for standalone mode"
```
---
### Task 7: Create useInstallPrompt hook
**Files:**
- Create: `apps/main/src/hooks/useInstallPrompt.ts`
- Create: `apps/main/src/hooks/useInstallPrompt.test.ts`
- [ ] **Step 1: Write the test file**
Create `apps/main/src/hooks/useInstallPrompt.test.ts`:
```typescript
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useInstallPrompt } from "./useInstallPrompt";
const DISMISSED_KEY = "pwa-install-dismissed";
describe("useInstallPrompt", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts with canInstall false and isStandalone false", () => {
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.canInstall).toBe(false);
expect(result.current.isStandalone).toBe(false);
expect(result.current.isDismissed).toBe(false);
});
it("captures beforeinstallprompt event and sets canInstall to true", () => {
const { result } = renderHook(() => useInstallPrompt());
const event = new Event("beforeinstallprompt");
Object.assign(event, { preventDefault: vi.fn(), prompt: vi.fn() });
act(() => {
window.dispatchEvent(event);
});
expect(result.current.canInstall).toBe(true);
});
it("dismiss persists to localStorage and sets isDismissed", () => {
const { result } = renderHook(() => useInstallPrompt());
act(() => {
result.current.dismiss();
});
expect(result.current.isDismissed).toBe(true);
expect(localStorage.getItem(DISMISSED_KEY)).toBe("true");
});
it("reads dismissed state from localStorage on mount", () => {
localStorage.setItem(DISMISSED_KEY, "true");
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.isDismissed).toBe(true);
});
it("detects iOS Safari", () => {
// Mock iOS Safari user agent
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
);
const { result } = renderHook(() => useInstallPrompt());
expect(result.current.isIOS).toBe(true);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
cd apps/main && pnpm vitest run src/hooks/useInstallPrompt.test.ts
```
Expected: FAIL — module `./useInstallPrompt` not found.
- [ ] **Step 3: Implement the hook**
Create `apps/main/src/hooks/useInstallPrompt.ts`:
```typescript
import { useCallback, useEffect, useRef, useState } from "react";
const DISMISSED_KEY = "pwa-install-dismissed";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function useInstallPrompt() {
const deferredPrompt = useRef<BeforeInstallPromptEvent | null>(null);
const [canInstall, setCanInstall] = useState(false);
const [isDismissed, setIsDismissed] = useState(
() => localStorage.getItem(DISMISSED_KEY) === "true"
);
const isStandalone =
typeof window !== "undefined" &&
window.matchMedia("(display-mode: standalone)").matches;
const isIOS =
typeof navigator !== "undefined" &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
deferredPrompt.current = e as BeforeInstallPromptEvent;
setCanInstall(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const promptInstall = useCallback(async () => {
if (!deferredPrompt.current) return;
const result = await deferredPrompt.current.prompt();
if (result.outcome === "accepted") {
deferredPrompt.current = null;
setCanInstall(false);
}
}, []);
const dismiss = useCallback(() => {
setIsDismissed(true);
localStorage.setItem(DISMISSED_KEY, "true");
}, []);
return { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss };
}
```
- [ ] **Step 4: Run the tests to verify they pass**
```bash
cd apps/main && pnpm vitest run src/hooks/useInstallPrompt.test.ts
```
Expected: All 5 tests pass.
- [ ] **Step 5: Commit**
```bash
git add apps/main/src/hooks/useInstallPrompt.ts apps/main/src/hooks/useInstallPrompt.test.ts
git commit -m "feat(pwa): add useInstallPrompt hook with tests"
```
---
### Task 8: Create InstallBanner component
**Files:**
- Create: `apps/main/src/components/InstallBanner.tsx`
- Create: `apps/main/src/components/InstallBanner.test.tsx`
- [ ] **Step 1: Write the test file**
Create `apps/main/src/components/InstallBanner.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock the hook so we can control its return values
const mockPromptInstall = vi.fn();
const mockDismiss = vi.fn();
vi.mock("../hooks/useInstallPrompt", () => ({
useInstallPrompt: vi.fn(() => ({
canInstall: false,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
})),
}));
import { useInstallPrompt } from "../hooks/useInstallPrompt";
import { InstallBanner } from "./InstallBanner";
const mockUseInstallPrompt = vi.mocked(useInstallPrompt);
describe("InstallBanner", () => {
beforeEach(() => {
mockPromptInstall.mockClear();
mockDismiss.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders nothing when canInstall is false and not iOS", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: false,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when already in standalone mode", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: true,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when dismissed", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: true,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
const { container } = render(<InstallBanner />);
expect(container.firstChild).toBeNull();
});
it("renders install banner when canInstall is true", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
expect(screen.getByText(/install/i)).toBeInTheDocument();
});
it("calls promptInstall when install button is clicked", async () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
await userEvent.click(screen.getByRole("button", { name: /install/i }));
expect(mockPromptInstall).toHaveBeenCalledOnce();
});
it("calls dismiss when close button is clicked", async () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: true,
isStandalone: false,
isIOS: false,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
await userEvent.click(screen.getByRole("button", { name: /dismiss|close/i }));
expect(mockDismiss).toHaveBeenCalledOnce();
});
it("renders iOS instructions when isIOS is true and not dismissed", () => {
mockUseInstallPrompt.mockReturnValue({
canInstall: false,
isStandalone: false,
isIOS: true,
isDismissed: false,
promptInstall: mockPromptInstall,
dismiss: mockDismiss,
});
render(<InstallBanner />);
expect(screen.getByText(/share/i)).toBeInTheDocument();
expect(screen.getByText(/add to home screen/i)).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
cd apps/main && pnpm vitest run src/components/InstallBanner.test.tsx
```
Expected: FAIL — module `./InstallBanner` not found.
- [ ] **Step 3: Implement the component**
Create `apps/main/src/components/InstallBanner.tsx`:
```tsx
import { X, Download, Share } from "lucide-react";
import { useInstallPrompt } from "../hooks/useInstallPrompt";
export function InstallBanner() {
const { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss } =
useInstallPrompt();
// Don't show if already installed, dismissed, or no install option available
if (isStandalone || isDismissed) return null;
if (!canInstall && !isIOS) return null;
return (
<div className="flex items-center gap-3 border-b border-border bg-card px-4 py-2.5 text-sm">
{isIOS ? (
<>
<Share className="size-4 shrink-0 text-muted-foreground" />
<p className="flex-1 text-foreground">
Install XTablo: tap{" "}
<span className="font-medium">Share</span> then{" "}
<span className="font-medium">Add to Home Screen</span>
</p>
</>
) : (
<>
<Download className="size-4 shrink-0 text-muted-foreground" />
<p className="flex-1 text-foreground">
Install XTablo for a faster, native experience
</p>
<button
type="button"
onClick={promptInstall}
className="shrink-0 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
aria-label="Install app"
>
Install
</button>
</>
)}
<button
type="button"
onClick={dismiss}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="size-4" />
</button>
</div>
);
}
```
The banner renders as a slim top bar using existing design tokens (bg-card, border-border, text-foreground, etc.) so it matches the app's theme automatically.
- [ ] **Step 4: Run the tests to verify they pass**
```bash
cd apps/main && pnpm vitest run src/components/InstallBanner.test.tsx
```
Expected: All 7 tests pass.
- [ ] **Step 5: Commit**
```bash
git add apps/main/src/components/InstallBanner.tsx apps/main/src/components/InstallBanner.test.tsx
git commit -m "feat(pwa): add InstallBanner component with tests"
```
---
### Task 9: Wire InstallBanner into the app shell
**Files:**
- Modify: `apps/main/src/App.tsx`
- [ ] **Step 1: Add InstallBanner to the App component**
In `apps/main/src/App.tsx`, import the component:
```typescript
import { InstallBanner } from "./components/InstallBanner";
```
Then add `<InstallBanner />` as the first child inside the main `<div className="min-h-screen bg-background">`, before `<Routes />`:
```tsx
<div className="min-h-screen bg-background">
<InstallBanner />
<Routes />
{showBanner && (
<CookieBanner
onAcceptAll={acceptAll}
onRejectAll={rejectAll}
onSavePreferences={saveConsent}
/>
)}
</div>
```
The banner appears at the very top of the page, above all content including the sidebar and top bar. It self-hides based on the hook state (dismissed, standalone, no install available).
- [ ] **Step 2: Build and typecheck**
```bash
cd apps/main && pnpm typecheck && pnpm build
```
Expected: Both pass with no errors.
- [ ] **Step 3: Commit**
```bash
git add apps/main/src/App.tsx
git commit -m "feat(pwa): wire InstallBanner into app shell"
```
---
### Task 10: Verify full PWA build end-to-end
**Files:** None (verification only)
- [ ] **Step 1: Clean build**
```bash
cd apps/main && pnpm clean && pnpm build
```
Expected: Build succeeds.
- [ ] **Step 2: Verify all PWA assets in dist/**
```bash
ls apps/main/dist/manifest.webmanifest apps/main/dist/sw.js apps/main/dist/pwa-icons/
```
Expected: `manifest.webmanifest` and `sw.js` exist. `pwa-icons/` folder contains all 6 generated icons.
- [ ] **Step 3: Verify manifest content**
```bash
cat apps/main/dist/manifest.webmanifest
```
Expected: JSON with `name: "XTablo"`, `display: "standalone"`, `theme_color: "#1e1b2e"`, 3 icons (192, 512, 512 maskable).
- [ ] **Step 4: Run all tests**
```bash
cd apps/main && pnpm test
```
Expected: All tests pass, including the new useInstallPrompt and InstallBanner tests.
- [ ] **Step 5: Run linter**
```bash
cd apps/main && pnpm lint
```
Expected: No new linting errors. Fix any issues before proceeding.
- [ ] **Step 6: Preview locally (manual verification)**
```bash
cd apps/main && pnpm preview
```
Open `http://localhost:4173` in Chrome. Open DevTools → Application tab:
- **Manifest** section should show "XTablo" with correct icons
- **Service Workers** section should show an active SW
- **Lighthouse** PWA audit should pass core checks (installable, has manifest, has SW)
This step is manual — verify visually, then stop the preview server.
- [ ] **Step 7: Final commit if any lint fixes were needed**
```bash
git add -A
git commit -m "fix(pwa): lint fixes"
```
Only run this if Step 5 required fixes. Skip if lint was clean.

View file

@ -0,0 +1,158 @@
# Dynamic PWA Manifest with Organization Logo
**Date**: 2026-04-02
**Based on**: [PWA Dynamic Logo Research](pwa-dynamic-logo-research.md)
**Approach**: Option A — Dynamic Worker Manifest with cookie-based org identification
---
## Overview
Enable organizations to upload a custom logo that replaces the default XTablo icons in the installed PWA. The Cloudflare Worker serves a dynamic `manifest.webmanifest` that includes org-specific icon URLs when a cookie identifies the current org. The server resizes the uploaded logo into all required PWA icon sizes and stores them in R2.
---
## 1. Database — `logo_url` on Organizations
**Migration**: Add a nullable `logo_url` column to the `organizations` table.
```sql
ALTER TABLE public.organizations ADD COLUMN logo_url text;
```
When null, the worker serves the default XTablo icons. When set, it contains the R2 base path for the org's icons (e.g., `org-icons/42/`).
---
## 2. R2 Storage — Icon Variants
**Bucket prefix**: `org-icons/{org_id}/`
**Generated sizes** (from a single uploaded image, minimum 512x512):
| File | Size | Purpose |
|------|------|---------|
| `icon-16.png` | 16x16 | Favicon |
| `icon-32.png` | 32x32 | Favicon |
| `icon-180.png` | 180x180 | Apple touch icon |
| `icon-192.png` | 192x192 | PWA icon |
| `icon-512.png` | 512x512 | PWA icon |
| `icon-512-maskable.png` | 512x512 | PWA maskable icon (with safe zone padding) |
**Resizing**: Done server-side with `sharp` on the API server (Cloud Run).
---
## 3. Logo Upload API
**Endpoint**: `PATCH /api/v1/users/organization` (extend existing)
The existing endpoint accepts `{ name }`. Extend to:
```typescript
{
name?: string;
logo?: { content: string; contentType: string } | null;
}
```
- `logo` with content: upload and resize the image, store in R2, set `logo_url`
- `logo: null`: delete icons from R2, clear `logo_url`
- `logo` omitted: no change to the logo
**Server-side flow**:
1. Validate: must be PNG, JPG, or WebP; minimum 512x512 pixels
2. Resize with `sharp` into the 6 variants listed above
3. Upload all variants to R2 at `org-icons/{org_id}/icon-{size}.png`
4. Update `organizations.logo_url` with the base path (`org-icons/{org_id}/`)
5. Return updated organization data
**Icon serving endpoint**: `GET /api/v1/org-icons/:orgId/icon-:size.png`
- Public (no auth required) — icons must be fetchable by the browser during PWA install
- Fetches from R2 and returns the image
- Cache headers: `Cache-Control: public, max-age=86400`
---
## 4. Cookie-Based Org Identification
**Cookie name**: `x-org-id`
**Set by**: Frontend, as a side effect in the `useOrganization` hook
**Value**: The organization's numeric `id`
**Attributes**: `path=/; secure; samesite=lax; max-age=31536000`
**Cleared on**: Logout
The cookie is read by the Cloudflare Worker when serving the manifest. It allows the worker to identify the org without requiring authentication (the manifest fetch is unauthenticated).
---
## 5. Dynamic Manifest in the Cloudflare Worker
**Intercept path**: `/manifest.webmanifest`
**Worker logic**:
1. Parse `x-org-id` cookie from the request
2. If cookie present: build manifest with org-specific icon URLs pointing to `/api/v1/org-icons/{org_id}/icon-{size}.png`
3. If cookie absent: build manifest with default XTablo icons from `/pwa-icons/`
4. Return JSON with `Content-Type: application/manifest+json`
The worker does not check whether the org actually has a logo. It always builds org-specific icon URLs when the cookie is present. The icon-serving API endpoint handles the fallback: if the requested org icon doesn't exist in R2, it returns the default XTablo icon for that size. This keeps the worker stateless (no DB or R2 access needed).
**Manifest content** (same structure as the current static manifest, only icons change):
```json
{
"name": "XTablo",
"short_name": "XTablo",
"description": "Collaborative project management for construction teams",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"theme_color": "#1e1b2e",
"background_color": "#1e1b2e",
"icons": [
{ "src": "/api/v1/org-icons/42/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/api/v1/org-icons/42/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/api/v1/org-icons/42/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
```
**Caching**: `Cache-Control: no-cache` on the manifest response. It's tiny JSON and must reflect the current org on every page load.
**vite-plugin-pwa change**: Set `manifest: false` in the VitePWA config to stop generating a static manifest. Keep the `<link rel="manifest" href="/manifest.webmanifest">` tag manually in `index.html`. The plugin continues to handle service worker generation and precaching.
---
## 6. Settings Page — Logo Upload UI
**Location**: Extend the organization section in `apps/main/src/pages/settings.tsx`
**UI elements**:
- Current org logo display (or placeholder when none set)
- Click-to-upload / drag-and-drop zone for a single image
- Client-side validation: image file type, minimum 512x512 resolution
- Sends base64 content to `PATCH /api/v1/users/organization` with the `logo` field
- On success: invalidates the org query cache (existing React Query pattern)
- "Remove logo" button: sends `logo: null` to clear
---
## 7. What's Explicitly Out of Scope
- **Runtime favicon swap** (`useOrgFavicon()` hook) — the static favicon is fine for now; the dynamic manifest handles the installed PWA icon
- **Subdomain-based org routing** — cookie-based identification is sufficient
- **Per-org PWA name** — always "XTablo"
- **Per-org theme_color** — always `#1e1b2e`
---
## 8. Known Limitations
| Limitation | Impact |
|------------|--------|
| iOS never updates installed PWA icons | Users must reinstall to see a new org logo |
| Android updates icons with multi-day delay | Not suitable for real-time branding changes |
| Cookie must be set before manifest fetch | First visit (unauthenticated) always gets default icons; org icons appear after login + next page load |
| Manifest `no-cache` means a worker request on every page load | Negligible cost — tiny JSON, fast worker response |

View file

@ -0,0 +1,180 @@
# PWA Design Spec: apps/main
**Date**: 2026-04-02
**Goal**: Make apps/main installable as a native-feeling PWA with fast repeat loads, focused on mobile experience polish. No offline data, no push notifications.
**Approach**: `vite-plugin-pwa` (Workbox-based) integrated into the existing Vite build.
---
## 1. Web App Manifest & Icons
### Manifest
Generated by `vite-plugin-pwa` from config in `vite.config.ts`. Key properties:
| Property | Value |
|----------|-------|
| `name` | XTablo |
| `short_name` | XTablo |
| `start_url` | `/` |
| `display` | `standalone` |
| `theme_color` | Matched to app header (from Tailwind config) |
| `background_color` | Matched to app background |
| `orientation` | `any` |
| `scope` | `/` |
### Icons
Generated from existing `icon.jpg` (production) and `staging_icon.jpg` (staging) using a one-time script (e.g., `sharp`). Output committed to `apps/main/public/pwa-icons/`.
Required sizes:
- `16x16`, `32x32` — favicons
- `180x180` — Apple touch icon
- `192x192` — Android standard
- `512x512` — Android standard
- `512x512 maskable` — Android adaptive icon
### HTML Meta Tags
Added to `apps/main/index.html`:
```html
<meta name="theme-color" content="#...">
<link rel="apple-touch-icon" href="/pwa-icons/apple-touch-icon-180x180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
```
Note: The manifest `<link>` is injected automatically by `vite-plugin-pwa`.
---
## 2. Service Worker & Caching Strategy
### Plugin Configuration
`vite-plugin-pwa` in `vite.config.ts` with:
- `registerType: 'autoUpdate'` — silent background updates, no user prompt
- `workbox.globPatterns` — precache all build outputs (JS, CSS, fonts, images)
### Caching Rules
| Resource | Strategy | Notes |
|----------|----------|-------|
| App shell (HTML, JS, CSS) | Precache | Auto-generated by Workbox from build manifest |
| Static assets (icons, logos) | Precache | Included in build output |
| API calls (`/api/v1/*`) | Network only | Not cached; errors handled by React Query |
| Supabase calls | Network only | Not cached; errors handled by React Query |
### SW Registration
In `main.tsx`, using `registerSW` from `virtual:pwa-register`. Handles registration lifecycle and auto-update.
### Update Flow
1. User opens/refreshes the app
2. SW detects new precache manifest in background
3. New assets downloaded silently
4. New version activates on next navigation/refresh
No "update available" toast — the app always requires connectivity, so silent updates are sufficient.
---
## 3. Install Prompt UX
### Custom Install Prompt
A React hook `useInstallPrompt` that:
- Listens for the `beforeinstallprompt` event
- Stores the deferred prompt event
- Exposes `canInstall: boolean` and `promptInstall(): void`
### UI
- Dismissible banner shown to authenticated users on the dashboard
- Dismissal persisted in `localStorage` (key: `pwa-install-dismissed`)
- Hidden when app is already running in standalone mode (detected via `window.matchMedia('(display-mode: standalone)')`)
### iOS Fallback
Safari does not support `beforeinstallprompt`. For iOS Safari users:
- Detect via user agent
- Show manual instruction: "Tap Share > Add to Home Screen"
- Same dismissal logic applies
---
## 4. Mobile Polish & Standalone Experience
### Viewport & Safe Areas
Update viewport meta tag:
```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
```
Add CSS safe area padding on the app shell:
```css
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
```
### Splash Screens
Simple approach — no custom splash images. Use `theme_color` + `background_color` in the manifest for Android. iOS uses `apple-mobile-web-app-status-bar-style` for a clean launch.
### Status Bar
- Android: Controlled by `theme_color` in manifest
- iOS: `apple-mobile-web-app-status-bar-style: default`
### Navigation
Standalone mode has no browser back button. The existing React Router navigation with in-app back buttons handles this. Verify no dead-end flows exist.
---
## 5. Environment & Deployment
### Environment Differences
| Aspect | Staging | Production |
|--------|---------|------------|
| Icon source | `staging_icon.jpg` | `icon.jpg` |
| Manifest name | XTablo (Staging) | XTablo |
| All other config | Same | Same |
### Cloudflare Workers
No changes to `wrangler.toml`. The SW file and manifest are static assets in `dist/`, served by Cloudflare like any other file. SPA routing (`not_found_handling = "single-page-application"`) is unaffected.
### Build Pipeline
- `vite-plugin-pwa` runs as part of normal `pnpm build` — no separate step
- Icon generation is a one-time script, output committed to repo
### Local Testing
- `pnpm dev`: SW disabled (standard behavior for vite-plugin-pwa)
- `pnpm build && pnpm preview`: Full PWA experience with active SW
### Impact
Fully additive. Users who don't install the PWA see no changes. The app works identically in a regular browser tab.
---
## Out of Scope
- Offline data access (all data requires connectivity)
- Push notifications (deferred to future work)
- Background sync
- Custom splash screen images (using color-based approach instead)
## Dependencies
- `vite-plugin-pwa` — Vite PWA integration
- `sharp` (dev dependency) — icon generation script

View file

@ -0,0 +1,219 @@
# PWA Dynamic Logo/Icon Research
**Date**: 2026-04-02
**Context**: Research into whether a PWA can display different logos/icons at runtime (e.g., per organization or per environment) and how to implement this in the XTablo codebase.
---
## Current State of the Codebase
The PWA has **not been implemented yet**. There is a design spec (`2026-04-02-pwa-design.md`) that describes the planned setup using `vite-plugin-pwa`, but no manifest, service worker, or PWA icons exist in the codebase today.
Relevant files:
- `apps/main/vite.config.ts` -- no PWA plugin configured
- `apps/main/public/` -- contains `icon.jpg`, `staging_icon.jpg`, logos, but no `pwa-icons/` directory
- `apps/main/worker/index.ts` -- minimal Cloudflare Worker stub (handles `/api/` prefix, otherwise 404)
- `apps/main/wrangler.toml` -- Cloudflare Workers config with `not_found_handling = "single-page-application"`
This means we have the opportunity to design the PWA with dynamic icons in mind from the start, rather than retrofitting.
---
## 1. Can the PWA Manifest Be Dynamic?
**Yes, but with significant caveats.**
### How it works
The `<link rel="manifest" href="...">` tag in HTML can point to any URL, including a dynamic endpoint. The browser fetches this URL to get the manifest JSON. So you could serve different manifests based on domain, cookie, query parameter, or any other request signal.
### Browser behavior on manifest fetch
- **Fetched once on page load** -- the browser reads the manifest when the page loads. It is not re-fetched on navigation within the SPA.
- **Cached aggressively** -- browsers cache the manifest. Chrome re-fetches it periodically (roughly every 24 hours for installed PWAs), but this is not standardized.
- **Determines install identity** -- the `start_url` + `id` fields in the manifest define the PWA's identity. Changing these creates a "different" PWA from the browser's perspective.
### Can a dynamic manifest change the installed PWA's icon?
- **Android (Chrome)**: Yes, partially. Chrome checks the manifest periodically and will update the home screen icon if the manifest has changed. However, the update is not immediate -- it can take days, and the user must visit the app.
- **iOS (Safari)**: **No.** Apple does not re-read the manifest after the PWA is added to the home screen. The icon is captured at install time and never updated. The only way to change it is for the user to delete and re-add the PWA.
- **Desktop (Chrome, Edge)**: Similar to Android. Periodic manifest checks can update the icon, but with delay.
**Bottom line**: A dynamic manifest is useful for serving the right icon at *install time*, but cannot reliably change icons for *already-installed* PWAs.
---
## 2. Approaches to Dynamic Logos
### Option A: Dynamic Manifest Endpoint via Cloudflare Worker
**How**: The Cloudflare Worker at `worker/index.ts` intercepts requests to `/manifest.webmanifest` (or `/manifest.json`) and returns a dynamically-generated JSON response. The manifest content is determined by the request hostname, a cookie, or a query parameter.
**In this codebase**: The worker is currently a stub. It could be extended to handle manifest requests:
```typescript
// Conceptual -- not for implementation
if (url.pathname === "/manifest.webmanifest") {
const isStaging = url.hostname.includes("staging");
const manifest = {
name: isStaging ? "XTablo (Staging)" : "XTablo",
icons: [
{ src: isStaging ? "/staging-icons/icon-192.png" : "/pwa-icons/icon-192.png", sizes: "192x192" },
// ...
],
// ...
};
return new Response(JSON.stringify(manifest), {
headers: { "Content-Type": "application/manifest+json" },
});
}
```
The `index.html` would use `<link rel="manifest" href="/manifest.webmanifest">` and the worker would serve the right one.
**Pros**:
- Clean separation: single HTML file, dynamic manifest
- Works well for the staging vs. production use case (different hostnames already exist)
- Could extend to per-organization icons if orgs have custom domains or subdomains
**Cons**:
- Adds logic to the worker that must stay in sync with icon files
- The `<link rel="manifest">` is fetched before React/JS runs, so you cannot use client-side state (auth, organization context) to influence which manifest is served -- only request-level signals (hostname, cookies)
- For per-organization branding, the org must be identifiable from the URL or a cookie at page load time
**Feasibility**: High for environment-based (staging/prod). Medium for per-org, depending on whether orgs are identifiable from the hostname.
### Option B: Multiple Static Manifests (Selected at Build Time)
**How**: Build separate manifests for each environment. The `vite-plugin-pwa` config reads environment variables and generates the right manifest.
**In this codebase**: The design spec already envisions this -- staging uses `staging_icon.jpg`, production uses `icon.jpg`. The `vite-plugin-pwa` config would use `process.env` to select names and icon paths.
**Pros**:
- Simplest approach
- Works perfectly for the staging/prod distinction
- No worker changes needed
- Fully static -- great caching, no runtime complexity
**Cons**:
- Cannot vary per-organization at runtime
- Each variant requires a separate build (fine for 2 environments, not viable for N organizations)
**Feasibility**: High for environment-based. Not viable for per-org.
### Option C: Runtime Favicon/Icon Swapping via JavaScript
**How**: JavaScript runs after page load and swaps `<link rel="icon">` and `<link rel="apple-touch-icon">` elements. Can also modify `<link rel="manifest">` href to point to different manifests.
**In this codebase**: A React hook or effect in `App.tsx` could read the current organization from Zustand/React Query and update favicon links accordingly.
**Pros**:
- Full access to client-side state (which org the user belongs to)
- Can change the browser tab favicon dynamically
- No worker or build changes needed
**Cons**:
- **Does not affect the installed PWA icon.** The manifest is read before JS runs. Changing `<link rel="manifest">` after page load has no effect on the installed PWA's icon.
- Brief flash of default icon before JS swaps it
- Apple touch icon must be set before the browser reads it (before JS runs)
**Feasibility**: Good for in-browser tab favicon only. Useless for installed PWA icons.
### Option D: Organization-Branded Theming (Colors, Not Icons)
**How**: Use `theme_color` in the manifest (and the HTML meta tag) to brand the PWA's title bar / splash screen per organization. Icons stay the same (XTablo branding), but colors vary.
**Pros**:
- Simpler than managing N icon sets
- `theme_color` can be set dynamically via the `<meta name="theme-color">` tag in JS (this actually works at runtime for the browser chrome)
- The manifest `theme_color` affects the installed PWA's title bar (set at install time)
**Cons**:
- Limited branding -- color only, not logo
- Same manifest caching issues as Option A for the installed app
- Runtime `<meta name="theme-color">` changes work in the browser but do not affect the installed PWA's title bar
**Feasibility**: Useful as a complementary approach. Not a full solution for dynamic logos.
---
## 3. Practical Constraints Summary
| Constraint | Impact |
|------------|--------|
| Manifest is fetched once per page load, before JS runs | Dynamic manifest must be determined from request-level signals (URL, cookies), not client-side state |
| iOS never updates installed PWA icons | Users must reinstall to see new icons; no workaround |
| Android updates icons with multi-day delay | Not suitable for real-time branding changes |
| `<meta name="theme-color">` works at runtime | Good for dynamic color theming in the browser |
| Cloudflare Workers can intercept any request | Worker-based dynamic manifest is technically straightforward |
| Organizations would need to be identifiable from URL/cookie | Per-org branding requires subdomain routing or a persistent cookie set before page load |
---
## 4. Recommended Approach for This Codebase
### For the initial PWA implementation: Option B (Static, Build-Time)
The design spec already handles this correctly. Two environments (staging, production) with different icons selected via environment variables in the `vite-plugin-pwa` config. This is the right approach for the first iteration.
### For future per-organization branding: Option A (Dynamic Worker Manifest) + Option C (Runtime Favicon)
If per-org branding becomes a requirement:
1. **Determine org from URL.** The most reliable approach is subdomain-based routing (e.g., `acme.xtablo.com`). This makes the org identifiable at the request level without cookies or JS.
2. **Worker serves dynamic manifest.** Extend `worker/index.ts` to intercept `/manifest.webmanifest` requests and return a manifest with org-specific icons, name, and theme_color. Icons would be stored in a known path pattern (e.g., `/org-icons/{org-slug}/icon-192.png`).
3. **Runtime favicon swap.** A React hook reads the current org from Zustand and updates `<link rel="icon">` for the browser tab experience. This handles the case where a user switches orgs without reloading.
4. **Accept the iOS limitation.** iOS users will see the icon from when they installed the PWA. Document this as a known limitation.
### What is NOT worth pursuing
- **Dynamically changing the manifest href via JavaScript after page load** -- browsers ignore this for install/icon purposes.
- **Building N manifests at build time for N organizations** -- does not scale and requires a deploy for each new org.
- **Service worker-level manifest interception** -- service workers do not intercept manifest fetches.
---
## 5. Implementation Plan (If Pursuing Dynamic Per-Org Icons)
This plan assumes the basic PWA (from the design spec) is already implemented.
### Phase 1: Subdomain routing
- Configure DNS wildcard for `*.xtablo.com`
- Update `wrangler.toml` routes to accept wildcard subdomains
- Add org resolution logic to the worker (subdomain -> org slug lookup)
### Phase 2: Org icon storage
- Define a convention for org icon paths in R2 or the public directory (e.g., `/org-icons/{slug}/icon-{size}.png`)
- Provide a default icon set as fallback
- Admin UI or API endpoint for uploading org icons (with automatic resizing via sharp or Cloudflare Image Resizing)
### Phase 3: Dynamic manifest in the worker
- Extend `worker/index.ts` to intercept `/manifest.webmanifest`
- Resolve org from subdomain
- Return manifest JSON with org-specific `name`, `icons`, and `theme_color`
- Set appropriate `Cache-Control` headers (short TTL for dynamic content, or `stale-while-revalidate`)
### Phase 4: Runtime favicon hook
- Create `useOrgFavicon()` hook
- On org change, update `<link rel="icon">` and `<meta name="theme-color">` in the document head
- Use org data already available from the Zustand user store
### Estimated effort
- Phase 1: Medium (DNS, worker routing, org resolution)
- Phase 2: Medium (icon pipeline, storage)
- Phase 3: Small (worker logic is straightforward)
- Phase 4: Small (React hook, DOM manipulation)
### Prerequisites
- Basic PWA must be implemented first (the design spec work)
- Organizations must have a `slug` or identifier suitable for subdomains
- Decision on whether to use subdomains vs. another org identification mechanism

View file

@ -37,6 +37,13 @@
"overrides": {
"form-data": "^4.0.4",
"linkifyjs": "^4.3.2"
},
"packageExtensions": {
"@hookform/resolvers": {
"peerDependencies": {
"zod": "*"
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
-- Add logo_url to organizations for storing the R2 base path to org icon variants
ALTER TABLE public.organizations ADD COLUMN logo_url text;
-- Add comment for clarity
COMMENT ON COLUMN public.organizations.logo_url IS 'R2 base path for org icon variants (e.g. org-icons/42/). NULL means default XTablo icons.';