diff --git a/apps/web/.env.example b/apps/web/.env.example index 9738b62..ea30833 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -16,11 +16,6 @@ ZENPDF_FREE_ACCOUNT_MAX_MB_PER_FILE= ZENPDF_FREE_ACCOUNT_MAX_CONCURRENT_JOBS= ZENPDF_FREE_ACCOUNT_MAX_JOBS_PER_DAY= ZENPDF_FREE_ACCOUNT_MAX_DAILY_MINUTES= -ZENPDF_PREMIUM_MAX_FILES_PER_JOB= -ZENPDF_PREMIUM_MAX_MB_PER_FILE= -ZENPDF_PREMIUM_MAX_CONCURRENT_JOBS= -ZENPDF_PREMIUM_MAX_JOBS_PER_DAY= -ZENPDF_PREMIUM_MAX_DAILY_MINUTES= ZENPDF_GLOBAL_MAX_CONCURRENT_JOBS= ZENPDF_GLOBAL_MAX_JOBS_PER_DAY= ZENPDF_GLOBAL_MAX_DAILY_MINUTES= @@ -28,6 +23,14 @@ ZENPDF_GLOBAL_JOB_MAX_ATTEMPTS= ZENPDF_GLOBAL_LEASE_DURATION_MS= ZENPDF_GLOBAL_ARTIFACT_TTL_HOURS= ZENPDF_WORKER_TOKEN= +NEXT_PUBLIC_DONATE_UPI_ID= +NEXT_PUBLIC_DONATE_PAYEE_NAME=ZenPDF +# Legacy key still supported by app fallback: NEXT_PUBLIC_DONATE_UPI_NAME +NEXT_PUBLIC_DONATE_UPI_NOTE="Support ZenPDF" +NEXT_PUBLIC_DONATE_UPI_QR_URL= +NEXT_PUBLIC_DONATE_ICON_LIGHT= +NEXT_PUBLIC_DONATE_ICON_DARK= +NEXT_PUBLIC_DONATE_CARD_EMBED_URL= NEXT_PUBLIC_COMPRESS_TIMEOUT_BASE_SECONDS=120 NEXT_PUBLIC_COMPRESS_TIMEOUT_MAX_SECONDS=900 NEXT_PUBLIC_COMPRESS_TIMEOUT_PER_MB_SECONDS=3 diff --git a/apps/web/convex/capacity.ts b/apps/web/convex/capacity.ts index 31321fd..30131f8 100644 --- a/apps/web/convex/capacity.ts +++ b/apps/web/convex/capacity.ts @@ -35,26 +35,28 @@ export const getUsageSnapshot = query({ const { userId, tier } = await resolveUser(ctx); const anonId = args.anonId?.trim() || undefined; const resolvedAnonId = userId ? undefined : anonId; - const [planLimits, anonLimits, freeLimits, premiumLimits, budget, usageResult] = + const [planLimits, anonLimits, freeLimits, globalLimits, budget, usageResult, globalUsageResult] = await Promise.all([ resolvePlanLimits(ctx, tier), resolvePlanLimits(ctx, "ANON"), resolvePlanLimits(ctx, "FREE_ACCOUNT"), - resolvePlanLimits(ctx, "PREMIUM"), + resolveGlobalLimits(ctx), resolveBudgetState(ctx, now), resolveUsageCounter(ctx, userId, resolvedAnonId, now), + resolveGlobalUsageCounter(ctx, now), ]); const planSnapshot = { ANON: anonLimits, FREE_ACCOUNT: freeLimits, - PREMIUM: premiumLimits, }; const { counter, periodStart } = usageResult; + const { counter: globalCounter, periodStart: globalPeriodStart } = globalUsageResult; return { tier, planLimits, plans: planSnapshot, + globalLimits, budget, usage: counter ?? { periodStart, @@ -62,6 +64,12 @@ export const getUsageSnapshot = query({ minutesUsed: 0, bytesProcessed: 0, }, + globalUsage: globalCounter ?? { + periodStart: globalPeriodStart, + jobsUsed: 0, + minutesUsed: 0, + bytesProcessed: 0, + }, }; }, }); diff --git a/apps/web/convex/lib/auth.ts b/apps/web/convex/lib/auth.ts index 155c772..4bb1285 100644 --- a/apps/web/convex/lib/auth.ts +++ b/apps/web/convex/lib/auth.ts @@ -10,48 +10,22 @@ type Ctx = MutationCtx | QueryCtx; export type ResolvedUser = { identity: UserIdentity | null; userId: Id<"users"> | undefined; - tier: "ANON" | "FREE_ACCOUNT" | "PREMIUM"; - adsFree: boolean; + tier: "ANON" | "FREE_ACCOUNT"; }; -const parseEnvList = (value: string | undefined) => - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? []; - const resolveTier = (identity: UserIdentity | null, storedTier?: ResolvedUser["tier"]) => { if (!identity) { - return { tier: "ANON" as const, adsFree: false }; - } - - const premiumEmails = parseEnvList(process.env.ZENPDF_PREMIUM_EMAILS).map((email) => - email.toLowerCase(), - ); - const premiumClerkIds = parseEnvList(process.env.ZENPDF_PREMIUM_CLERK_IDS); - const hasAllowlist = premiumEmails.length > 0 || premiumClerkIds.length > 0; - const email = normalizeOptionalEmail(identity.email); - const isPremium = - (email ? premiumEmails.includes(email) : false) || - premiumClerkIds.includes(identity.subject); - - if (isPremium) { - return { tier: "PREMIUM" as const, adsFree: true }; + return { tier: "ANON" as const }; } - - if (hasAllowlist) { - return { tier: "FREE_ACCOUNT" as const, adsFree: false }; - } - const tier = storedTier ?? "FREE_ACCOUNT"; - return { tier, adsFree: tier === "PREMIUM" }; + return { tier }; }; export const resolveUser = async (ctx: Ctx): Promise => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { - return { identity: null, userId: undefined, tier: "ANON", adsFree: false }; + return { identity: null, userId: undefined, tier: "ANON" }; } const clerkUserId = identity.subject; @@ -60,13 +34,13 @@ export const resolveUser = async (ctx: Ctx): Promise => { .withIndex("by_clerk_id", (q) => q.eq("clerkUserId", clerkUserId)) .unique(); - const { tier, adsFree } = resolveTier(identity, existing?.tier); + const { tier } = resolveTier(identity, existing?.tier); if (existing) { - return { identity, userId: existing._id, tier, adsFree }; + return { identity, userId: existing._id, tier }; } - return { identity, userId: undefined, tier, adsFree }; + return { identity, userId: undefined, tier }; }; export const resolveOrCreateUser = async ( @@ -84,7 +58,6 @@ export const resolveOrCreateUser = async ( email: normalizeOptionalEmail(resolved.identity.email), name: resolved.identity.name ?? resolved.identity.nickname, tier: resolved.tier, - adsFree: resolved.adsFree, createdAt: now, updatedAt: now, }); @@ -93,11 +66,9 @@ export const resolveOrCreateUser = async ( } const existing = await ctx.db.get(resolved.userId); - const storedAdsFree = existing?.adsFree ?? false; - if (existing && (existing.tier !== resolved.tier || storedAdsFree !== resolved.adsFree)) { + if (existing && existing.tier !== resolved.tier) { await ctx.db.patch(resolved.userId, { tier: resolved.tier, - adsFree: resolved.adsFree, updatedAt: now, }); } diff --git a/apps/web/convex/lib/limits.ts b/apps/web/convex/lib/limits.ts index 3659f12..c7553f9 100644 --- a/apps/web/convex/lib/limits.ts +++ b/apps/web/convex/lib/limits.ts @@ -1,6 +1,6 @@ import type { MutationCtx, QueryCtx } from "../_generated/server"; -export type PlanTier = "ANON" | "FREE_ACCOUNT" | "PREMIUM"; +export type PlanTier = "ANON" | "FREE_ACCOUNT"; export type PlanLimits = { maxFilesPerJob: number; @@ -22,31 +22,24 @@ export type GlobalLimits = { const DEFAULT_PLAN_LIMITS: Record = { ANON: { maxFilesPerJob: 1, - maxMbPerFile: 10, + maxMbPerFile: 25, maxConcurrentJobs: 1, - maxJobsPerDay: 3, + maxJobsPerDay: 5, maxDailyMinutes: 10, }, FREE_ACCOUNT: { maxFilesPerJob: 3, - maxMbPerFile: 50, + maxMbPerFile: 75, maxConcurrentJobs: 2, - maxJobsPerDay: 25, + maxJobsPerDay: 30, maxDailyMinutes: 60, }, - PREMIUM: { - maxFilesPerJob: 10, - maxMbPerFile: 250, - maxConcurrentJobs: 4, - maxJobsPerDay: 200, - maxDailyMinutes: 240, - }, }; const DEFAULT_GLOBAL_LIMITS: GlobalLimits = { - maxConcurrentJobs: 20, - maxJobsPerDay: 1500, - maxDailyMinutes: 12000, + maxConcurrentJobs: 3, + maxJobsPerDay: 200, + maxDailyMinutes: 120, jobMaxAttempts: 3, leaseDurationMs: 2 * 60 * 1000, artifactTtlHours: 24, diff --git a/apps/web/convex/schema.ts b/apps/web/convex/schema.ts index d0b13fc..cee0b20 100644 --- a/apps/web/convex/schema.ts +++ b/apps/web/convex/schema.ts @@ -4,7 +4,6 @@ import { v } from "convex/values"; const tier = v.union( v.literal("ANON"), v.literal("FREE_ACCOUNT"), - v.literal("PREMIUM"), ); const jobStatus = v.union( @@ -20,8 +19,9 @@ export default defineSchema({ clerkUserId: v.string(), email: v.optional(v.string()), name: v.optional(v.string()), - tier, + // Legacy field kept optional so existing records remain schema-valid. adsFree: v.optional(v.boolean()), + tier, createdAt: v.number(), updatedAt: v.number(), }) diff --git a/apps/web/convex/users.ts b/apps/web/convex/users.ts index 08106d8..8d5b2d9 100644 --- a/apps/web/convex/users.ts +++ b/apps/web/convex/users.ts @@ -5,10 +5,9 @@ import { resolveUser } from "./lib/auth"; export const getViewer = query({ args: {}, handler: async (ctx) => { - const { identity, tier, adsFree } = await resolveUser(ctx); + const { identity, tier } = await resolveUser(ctx); return { tier, - adsFree, signedIn: Boolean(identity), }; }, diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 05e726d..3349011 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Convex generated code should not be linted. + "convex/_generated/**", ]), ]); diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index e87a3c9..fd5d7b6 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -11,12 +11,14 @@ "@clerk/nextjs": "^6.36.10", "convex": "^1.31.7", "next": "16.1.4", + "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "convex-test": "^0.0.41", @@ -2458,6 +2460,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", @@ -3167,11 +3179,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3557,6 +3577,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001766", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", @@ -3610,11 +3639,21 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3627,7 +3666,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -3788,6 +3826,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3850,6 +3897,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4821,6 +4874,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5307,6 +5369,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6428,6 +6499,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6445,7 +6525,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6494,6 +6573,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6580,6 +6668,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6673,6 +6778,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6871,6 +6991,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7137,6 +7263,26 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -7250,6 +7396,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8009,6 +8167,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -8058,6 +8222,26 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8065,6 +8249,93 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index c2d1289..8cef548 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,12 +14,14 @@ "@clerk/nextjs": "^6.36.10", "convex": "^1.31.7", "next": "16.1.4", + "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "convex-test": "^0.0.41", diff --git a/apps/web/public/icons/chai-fab-dark.png b/apps/web/public/icons/chai-fab-dark.png new file mode 100644 index 0000000..5b52403 Binary files /dev/null and b/apps/web/public/icons/chai-fab-dark.png differ diff --git a/apps/web/public/icons/chai-fab-light.png b/apps/web/public/icons/chai-fab-light.png new file mode 100644 index 0000000..6827fa7 Binary files /dev/null and b/apps/web/public/icons/chai-fab-light.png differ diff --git a/apps/web/public/icons/chai.png b/apps/web/public/icons/chai.png new file mode 100644 index 0000000..4afc646 Binary files /dev/null and b/apps/web/public/icons/chai.png differ diff --git a/apps/web/src/app/api/download/route.ts b/apps/web/src/app/api/download/route.ts index b71f0f6..c11d5e4 100644 --- a/apps/web/src/app/api/download/route.ts +++ b/apps/web/src/app/api/download/route.ts @@ -38,9 +38,11 @@ export async function GET(request: NextRequest): Promise { const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? "http://localhost:3210"; const convex = new ConvexHttpClient(convexUrl); - const disableAuth = + const bypassAllowed = process.env.ZENPDF_DISABLE_AUTH === "1" && process.env.NODE_ENV !== "production"; + const disableAuth = + bypassAllowed && request.headers.get("x-zenpdf-auth-bypassed") === "1"; if (!disableAuth) { try { const { getToken } = getAuth(request); diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 651f8c0..797ecbb 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,263 +1,935 @@ @import "tailwindcss"; :root { - --paper-50: 248 243 232; - --paper-100: 242 232 214; - --paper-200: 229 213 187; - --paper-300: 214 191 154; - --ink-900: 52 40 30; - --ink-700: 90 72 58; - --ink-500: 119 101 85; - --forest-700: 46 78 55; - --forest-600: 63 98 70; - --forest-500: 83 125 86; - --sage-200: 210 224 206; - --rose-100: 246 235 224; - --gold-200: 229 210 179; - --wood-800: 84 56 36; - --wood-700: 101 68 43; - --wood-600: 123 84 55; - --wood-500: 145 104 71; - --wood-400: 171 130 96; - --leaf-300: 199 214 188; - --leaf-500: 108 149 112; - --leaf-700: 73 111 77; - --paper-noise: url("data:image/svg+xml;utf8,"); - --leaf-pattern: url("data:image/svg+xml;utf8,"); - --leaf-icon: url("data:image/svg+xml;utf8,"); + color-scheme: light; + --paper-50: 249 251 250; + --paper-100: 243 247 245; + --paper-200: 229 235 232; + --paper-300: 211 221 215; + --ink-900: 15 23 42; + --ink-700: 51 65 85; + --ink-500: 100 116 139; + --forest-700: 18 105 74; + --forest-600: 22 136 95; + --forest-500: 52 173 127; + --sage-200: 220 246 233; + --rose-100: 254 242 242; + --rose-200: 254 226 226; + --gold-200: 254 243 199; + --wood-800: 55 65 81; + --wood-700: 71 85 105; + --wood-600: 100 116 139; + --wood-500: 148 163 184; + --wood-400: 203 213 225; + --leaf-300: 187 247 208; + --leaf-500: 34 197 94; + --leaf-700: 21 128 61; + --page-bg-top: 248 252 250; + --page-bg-bottom: 255 255 255; + --page-accent: 220 246 233; + --shadow-ink: 15 23 42; } -body { - min-height: 100%; - background-color: rgb(var(--paper-50)); - background-image: - radial-gradient( - circle at top, - rgb(252 246 234 / 0.9), - transparent 55% - ), - radial-gradient( - circle at 12% 25%, - rgb(229 212 186 / 0.55), - transparent 45% - ), - var(--leaf-pattern); - background-size: auto, auto, 260px 260px; - background-repeat: no-repeat, no-repeat, repeat; - background-attachment: fixed; - color: rgb(var(--ink-900)); - font-family: var(--font-body), serif; - position: relative; - isolation: isolate; +:root[data-theme="dark"] { + color-scheme: dark; + /* Dark theme surface ladder: page < primary surface < nested surface */ + --paper-50: 40 40 40; + --paper-100: 48 48 48; + --paper-200: 67 67 67; + --paper-300: 98 98 98; + --ink-900: 246 246 246; + --ink-700: 208 208 208; + --ink-500: 165 165 165; + /* Single Supabase green accent */ + --forest-700: 63 207 142; + --forest-600: 63 207 142; + --forest-500: 63 207 142; + --sage-200: 48 48 48; + --rose-100: 78 38 38; + --rose-200: 96 44 44; + --gold-200: 78 62 30; + --wood-800: 22 22 22; + --wood-700: 28 28 28; + --wood-600: 36 36 36; + --wood-500: 48 48 48; + --wood-400: 72 72 72; + --leaf-300: 169 241 202; + --leaf-500: 63 207 142; + --leaf-700: 63 207 142; + --page-bg-top: 25 25 25; + --page-bg-bottom: 31 31 31; + --page-accent: 42 42 42; + --shadow-ink: 10 10 10; } -body::before { - content: ""; - position: fixed; - inset: 0; - background-image: var(--paper-noise); - opacity: 0.22; - pointer-events: none; - z-index: 0; -} - -body::after { - content: ""; - position: fixed; - inset: 12px; - border-radius: 26px; - border: 6px solid rgb(var(--wood-500) / 0.35); - box-shadow: - inset 0 0 0 2px rgb(255 255 255 / 0.35), - 0 22px 45px -30px rgba(84, 56, 36, 0.65); - pointer-events: none; - z-index: 1; +html, +body { + min-height: 100%; } -body > * { - position: relative; - z-index: 2; +body { + background: + radial-gradient(circle at top right, rgb(var(--page-accent) / 0.28), transparent 35%), + linear-gradient(180deg, rgb(var(--page-bg-top)), rgb(var(--page-bg-bottom))); + color: rgb(var(--ink-900)); + font-family: var(--font-body), sans-serif; + line-height: 1.5; } a { color: inherit; + text-decoration: none; } h1, h2, h3, h4 { - font-family: var(--font-display), serif; + font-family: var(--font-display), sans-serif; color: rgb(var(--ink-900)); - letter-spacing: -0.01em; + letter-spacing: -0.015em; +} + +:focus-visible { + outline: 2px solid rgb(var(--forest-600)); + outline-offset: 2px; } .paper-card { - background: - linear-gradient( - 165deg, - rgb(var(--paper-50)), - rgb(var(--paper-100)) - ), - var(--leaf-pattern); - background-size: auto, 220px 220px; - background-repeat: no-repeat, repeat; - border-radius: 26px; - border: 2px solid rgb(var(--wood-400) / 0.55); + background: rgb(var(--paper-50) / 0.96); + border: 1px solid rgb(var(--paper-200)); + border-radius: 16px; box-shadow: - 0 18px 40px -28px rgba(60, 40, 22, 0.65), - 0 8px 16px -10px rgba(60, 40, 22, 0.35), - inset 0 1px 0 rgba(255, 255, 255, 0.75); + 0 1px 2px rgb(var(--shadow-ink) / 0.2), + 0 12px 24px -20px rgb(var(--shadow-ink) / 0.45); } .paper-stack { - background: linear-gradient( - 160deg, - rgb(var(--paper-100)), - rgb(var(--paper-200)) - ); - border-radius: 32px; - border: 2px solid rgb(var(--wood-400) / 0.4); + background: rgb(var(--paper-100)); + border: 1px solid rgb(var(--paper-200)); + border-radius: 18px; box-shadow: - 0 28px 60px -40px rgba(60, 40, 22, 0.55), - 0 10px 22px -16px rgba(60, 40, 22, 0.3); + inset 0 1px 0 rgb(255 255 255 / 0.08), + 0 16px 30px -24px rgb(var(--shadow-ink) / 0.6); } -.paper-button { +.paper-button, +.paper-button--ghost, +.paper-button--danger { display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; - background: linear-gradient( - 180deg, - rgb(var(--forest-500)), - rgb(var(--forest-700)) - ); - color: rgb(var(--paper-50)); - border-radius: 999px; - padding: 0.7rem 1.6rem; + border-radius: 10px; + padding: 0.625rem 0.95rem; + font-size: 0.875rem; font-weight: 600; - letter-spacing: 0.01em; - box-shadow: - 0 12px 24px -18px rgba(40, 62, 38, 0.75), - inset 0 1px 0 rgba(255, 255, 255, 0.25); - transition: transform 0.2s ease, box-shadow 0.2s ease, - background 0.2s ease; + line-height: 1; + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, + box-shadow 0.18s ease, transform 0.12s ease; +} + +.paper-button { + border: 1px solid rgb(var(--forest-700)); + background: rgb(var(--forest-600)); + color: rgb(255 255 255); + box-shadow: 0 10px 22px -16px rgb(18 105 74 / 0.6); } .paper-button::before { - content: ""; - width: 16px; - height: 16px; - background-image: var(--leaf-icon); - background-size: contain; - background-repeat: no-repeat; - display: inline-block; + content: none; } .paper-button:hover { - background: linear-gradient( - 180deg, - rgb(var(--forest-500)), - rgb(var(--forest-600)) - ); - transform: translateY(-1px); + background: rgb(var(--forest-700)); +} + +.paper-button:active { + transform: translateY(1px); +} + +.paper-button:disabled { + cursor: not-allowed; + opacity: 0.65; } .paper-button--ghost { + border: 1px solid rgb(var(--paper-300)); background: rgb(var(--paper-50)); color: rgb(var(--ink-700)); - border-radius: 999px; - padding: 0.55rem 1.3rem; - border: 2px solid rgb(var(--wood-400) / 0.5); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.75), - 0 8px 16px -12px rgba(60, 40, 22, 0.35); - transition: background 0.2s ease, color 0.2s ease; } .paper-button--ghost:hover { - background: rgb(var(--paper-100)); - color: rgb(var(--ink-900)); + border-color: rgb(var(--forest-500) / 0.5); + color: rgb(var(--forest-700)); +} + +.paper-button--ghost[aria-pressed="true"] { + border-color: rgb(var(--forest-500) / 0.5); + background: rgb(var(--sage-200) / 0.65); + color: rgb(var(--forest-700)); +} + +.paper-button--danger { + border: 1px solid rgb(var(--rose-200) / 0.8); + background: rgb(var(--rose-100)); + color: rgb(153 27 27); +} + +.paper-button--danger:hover { + background: rgb(var(--rose-200)); } .ink-label { text-transform: uppercase; - letter-spacing: 0.24em; - font-size: 0.65rem; + letter-spacing: 0.16em; + font-size: 0.68rem; + font-weight: 600; color: rgb(var(--ink-500)); } .ink-divider { height: 1px; - background: linear-gradient( - 90deg, - rgb(var(--ink-900) / 0), - rgb(var(--wood-600) / 0.35), - rgb(var(--ink-900) / 0) - ); + background: rgb(var(--paper-200)); } .wood-nav { - background: - linear-gradient( - 180deg, - rgb(var(--wood-500)), - rgb(var(--wood-700)) - ); - border-radius: 999px; - border: 2px solid rgb(var(--wood-800) / 0.6); + border: 1px solid rgb(var(--paper-200)); + background: rgb(var(--paper-50) / 0.9); + border-radius: 14px; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.25), - 0 14px 24px -18px rgba(60, 40, 22, 0.6); - padding: 0.65rem 1.25rem; + 0 1px 2px rgb(var(--shadow-ink) / 0.24), + 0 10px 24px -22px rgb(var(--shadow-ink) / 0.6); } .wood-nav a, .wood-nav button { - color: rgb(var(--paper-50)); + color: inherit; } .wood-nav .paper-button--ghost { - background: rgb(var(--paper-50) / 0.16); - color: rgb(var(--paper-50)); - border-color: rgb(255 255 255 / 0.25); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); + background: transparent; } .wood-nav .paper-button { - background: linear-gradient( - 180deg, - rgb(var(--leaf-500)), - rgb(var(--leaf-700)) - ); + color: rgb(255 255 255); +} + +.wood-nav .paper-button:hover, +.wood-nav .paper-button:focus-visible { + color: rgb(255 255 255); +} + +.nav-link { + border: 1px solid transparent; + border-radius: 10px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 600; + color: rgb(var(--ink-700)); + transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease; +} + +.nav-link:hover { + border-color: rgb(var(--paper-300)); + background: rgb(var(--paper-50) / 0.85); +} + +.nav-link--active { + border-color: rgb(var(--forest-500) / 0.5); + background: rgb(var(--sage-200) / 0.7); + color: rgb(var(--forest-700)); } .tool-card { + border-color: rgb(var(--paper-200)); +} + +.tool-card:hover { + border-color: rgb(var(--forest-500) / 0.5); +} + +.tool-group-label { + margin-top: 0.9rem; + margin-bottom: 0.35rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgb(var(--ink-500)); +} + +.task-step { + border-radius: 10px; + border: 1px solid rgb(var(--paper-200)); + background: rgb(var(--paper-50) / 0.95); + padding: 0.75rem; +} + +.task-step-index { + display: inline-flex; + height: 1.5rem; + width: 1.5rem; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgb(var(--paper-300)); + background: rgb(var(--paper-100)); + font-size: 0.72rem; + font-weight: 700; + color: rgb(var(--forest-700)); +} + +.surface-muted { + border: 1px solid rgb(var(--paper-200)); + background: rgb(var(--paper-100)); + border-radius: 12px; +} + +.status-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid rgb(var(--paper-300)); + padding: 0.35rem 0.7rem; + font-size: 0.72rem; + font-weight: 600; + color: rgb(var(--ink-700)); +} + +.status-pill--success { + border-color: rgb(var(--forest-500) / 0.4); + background: rgb(var(--sage-200)); + color: rgb(var(--forest-700)); +} + +.status-pill--warning { + border-color: rgb(202 138 4 / 0.35); + background: rgb(var(--gold-200) / 0.35); + color: rgb(133 77 14); +} + +.status-pill--error { + border-color: rgb(220 38 38 / 0.35); + background: rgb(var(--rose-100)); + color: rgb(153 27 27); +} + +.alert { + border-radius: 12px; + border: 1px solid rgb(var(--paper-300)); + background: rgb(var(--paper-100)); + padding: 0.75rem 0.9rem; + font-size: 0.82rem; + color: rgb(var(--ink-700)); +} + +.alert--success { + border-color: rgb(var(--forest-500) / 0.4); + background: rgb(var(--sage-200)); + color: rgb(var(--forest-700)); +} + +.alert--error { + border-color: rgb(220 38 38 / 0.35); + background: rgb(var(--rose-100)); + color: rgb(153 27 27); +} + +.field-label { + display: block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgb(var(--ink-500)); +} + +.field-input { + margin-top: 0.45rem; + width: 100%; + border-radius: 10px; + border: 1px solid rgb(var(--paper-300)); + background: rgb(var(--paper-50)); + padding: 0.67rem 0.75rem; + font-size: 0.875rem; + color: rgb(var(--ink-900)); +} + +.field-input::placeholder { + color: rgb(var(--ink-500)); +} + +.field-input:focus { + border-color: rgb(var(--forest-500)); + box-shadow: 0 0 0 3px rgb(var(--forest-500) / 0.15); + outline: none; +} + +.field-helper { + margin-top: 0.35rem; + font-size: 0.74rem; + color: rgb(var(--ink-500)); +} + +/* Clerk user profile popover readability overrides */ +.cl-userButtonPopoverCard { + overflow: hidden; +} + +.cl-userButtonPopoverMain, +.cl-userButtonPopoverActions, +.cl-userButtonPopoverFooter { + background: rgb(var(--paper-50)) !important; +} + +.cl-userButtonPopoverActions { + border-top: 1px solid rgb(var(--paper-200)) !important; +} + +.cl-userButtonPopoverActionButton { + min-height: 3.25rem; + font-weight: 600; + color: rgb(var(--ink-900)) !important; + opacity: 1 !important; + transition: background-color 0.18s ease, color 0.18s ease; +} + +.cl-userButtonPopoverActionButton + .cl-userButtonPopoverActionButton { + border-top: 1px solid rgb(var(--paper-200)); +} + +.cl-userButtonPopoverActionButton:hover, +.cl-userButtonPopoverActionButton:focus-visible { + background: rgb(var(--paper-100)) !important; + color: rgb(var(--ink-900)) !important; +} + +.cl-userButtonPopoverActionButton * { + color: inherit !important; + opacity: 1 !important; +} + +.cl-userButtonPopoverActionButtonIcon, +.cl-userButtonPopoverActionButtonIconBox, +.cl-userButtonPopoverActionButton svg { + color: rgb(var(--ink-700)) !important; +} + +.cl-userPreviewMainIdentifier, +.cl-userPreviewMainIdentifierText { + color: rgb(var(--ink-900)) !important; + opacity: 1 !important; +} + +.cl-userPreviewSecondaryIdentifier { + color: rgb(var(--ink-700)) !important; + opacity: 1 !important; +} + +.cl-userButtonPopoverFooter { + border-top: 1px solid rgb(var(--paper-200)) !important; + background: rgb(var(--paper-100)) !important; + color: rgb(var(--ink-700)) !important; +} + +.cl-userButtonPopoverFooter * { + color: inherit !important; + opacity: 1 !important; +} + +.theme-toggle { + width: 2rem; + height: 2rem; + min-width: 2rem; + border-radius: 999px; + padding: 0; +} + +.theme-toggle svg { + width: 1rem; + height: 1rem; +} + +.zen-user-popover-root { + z-index: 70; +} + +.zen-user-popover-card { + min-width: min(23rem, calc(100vw - 1.25rem)); + border-radius: 16px; + border: 1px solid rgb(var(--paper-200)); + background: rgb(var(--paper-50)); + box-shadow: + 0 1px 2px rgb(var(--shadow-ink) / 0.2), + 0 20px 42px -26px rgb(var(--shadow-ink) / 0.55); +} + +.zen-user-preview { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.85rem; + align-items: center; +} + +.zen-user-preview-avatar-wrap { + margin: 0; +} + +.zen-user-preview-avatar-box { + border: none; + box-shadow: none; +} + +.zen-user-preview-text { + row-gap: 0.2rem; +} + +.zen-user-preview-name { + color: rgb(var(--ink-900)); + font-size: 1.02rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.zen-user-preview-email { + color: rgb(var(--ink-700)); + font-size: 0.9rem; + line-height: 1.35; +} + +.zen-user-popover-actions { + border-top: 1px solid rgb(var(--paper-200)); +} + +.zen-user-popover-action { + min-height: 3.15rem; + padding-inline: 1rem; + color: rgb(var(--ink-900)); + font-weight: 600; + opacity: 1; + transition: background-color 0.16s ease, color 0.16s ease; +} + +.zen-user-popover-action * { + opacity: 1; +} + +.zen-user-popover-action:hover, +.zen-user-popover-action:focus-visible { + background: rgb(var(--paper-100)); + color: rgb(var(--ink-900)); +} + +.zen-user-popover-action-iconbox, +.zen-user-popover-action-icon { + color: rgb(var(--ink-700)); + opacity: 1; +} + +.zen-user-popover-footer { + border-top: 1px solid rgb(var(--paper-200)); + color: rgb(var(--ink-700)); +} + +.zen-user-popover-footer-link { + color: rgb(var(--ink-700)); +} + +.zen-user-popover-footer-link:hover, +.zen-user-popover-footer-link:focus-visible { + color: rgb(var(--ink-900)); +} + +.donate-fab { + position: fixed; + right: 1.2rem; + bottom: 1.2rem; + z-index: 60; + display: inline-flex; + align-items: center; + justify-content: center; + width: 5.25rem; + height: 5.25rem; + border-radius: 1.15rem; + border: 1px solid rgb(var(--forest-700) / 0.85); + background: rgb(var(--forest-600)); + color: rgb(255 255 255); + box-shadow: + 0 18px 30px -18px rgb(var(--shadow-ink) / 0.75), + inset 0 1px 0 rgb(255 255 255 / 0.24); + transition: transform 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease, + border-color 0.16s ease; +} + +.donate-fab.donate-fab--with-image { + background: transparent; + border-color: transparent; + box-shadow: + 0 10px 22px -14px rgb(var(--shadow-ink) / 0.55), + 0 4px 12px -10px rgb(var(--shadow-ink) / 0.35); +} + +.donate-fab.donate-fab--with-image:hover { + background: transparent; + box-shadow: + 0 16px 30px -14px rgb(var(--shadow-ink) / 0.68), + 0 8px 18px -12px rgb(var(--shadow-ink) / 0.45); +} + +.donate-fab-hint { + position: absolute; + right: 1rem; + bottom: calc(100% + 0.7rem); + transform: translateY(4px); + opacity: 0; + pointer-events: none; + white-space: nowrap; + border-radius: 12px; + border: 1px solid rgb(var(--paper-300)); + background: rgb(var(--paper-50)); + color: rgb(var(--ink-900)); + padding: 0.58rem 0.95rem; + font-size: 0.95rem; + font-weight: 600; + line-height: 1; + box-shadow: + 0 12px 24px -16px rgb(var(--shadow-ink) / 0.85), + 0 1px 0 rgb(255 255 255 / 0.16); + transition: opacity 0.16s ease, transform 0.16s ease; +} + +.donate-fab-hint::after { + content: ""; + position: absolute; + right: 3.9rem; + top: 100%; + width: 11px; + height: 11px; + background: rgb(var(--paper-50)); + border-right: 1px solid rgb(var(--paper-300)); + border-bottom: 1px solid rgb(var(--paper-300)); + transform: translateY(-5px) rotate(45deg); +} + +.donate-fab:hover .donate-fab-hint, +.donate-fab:focus-visible .donate-fab-hint { + opacity: 1; + transform: translateY(0); +} + +.donate-fab:hover { + transform: translateY(-1px) scale(1.01); + background: rgb(var(--forest-700)); + box-shadow: + 0 20px 32px -16px rgb(var(--shadow-ink) / 0.85), + inset 0 1px 0 rgb(255 255 255 / 0.28); +} + +.donate-fab:active { + transform: translateY(0) scale(0.995); +} + +.donate-fab svg { + width: 1.7rem; + height: 1.7rem; +} + +.donate-fab-icon-image { + display: inline-block; + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 0.95rem; + filter: drop-shadow(0 8px 14px rgb(var(--shadow-ink) / 0.28)); + transition: filter 0.18s ease; +} + +.donate-fab.donate-fab--with-image:hover .donate-fab-icon-image { + filter: drop-shadow(0 12px 20px rgb(var(--shadow-ink) / 0.42)); +} + +.donate-close-button { + width: auto; +} + +.donate-card-frame { + display: block; + width: 100%; + min-height: 25rem; + border: 0; + border-radius: 10px; + background: rgb(var(--paper-50)); +} + +:root[data-theme="dark"] body { background: - linear-gradient( - 160deg, - rgb(var(--paper-50)), - rgb(var(--paper-100)) - ), - var(--leaf-pattern); - border: 2px solid rgb(var(--wood-400) / 0.65); + radial-gradient(circle at top center, rgb(255 255 255 / 0.04), transparent 42%), + linear-gradient(180deg, rgb(var(--page-bg-top)), rgb(var(--page-bg-bottom))); +} + +:root[data-theme="dark"] .paper-card { + background: rgb(var(--paper-50) / 0.96); + border-color: rgb(var(--paper-200) / 0.95); + box-shadow: + 0 1px 0 rgb(255 255 255 / 0.04), + 0 16px 30px -24px rgb(0 0 0 / 0.85); +} + +:root[data-theme="dark"] .paper-stack { + background: rgb(var(--paper-100) / 0.95); + border-color: rgb(var(--paper-200)); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.05), + 0 18px 34px -24px rgb(0 0 0 / 0.85); +} + +:root[data-theme="dark"] .wood-nav { + border-color: rgb(var(--paper-200)); + background: rgb(var(--paper-50) / 0.9); + box-shadow: + 0 1px 0 rgb(255 255 255 / 0.05), + 0 16px 34px -24px rgb(0 0 0 / 0.86); +} + +:root[data-theme="dark"] .surface-muted, +:root[data-theme="dark"] .task-step, +:root[data-theme="dark"] .field-input, +:root[data-theme="dark"] .tool-card, +:root[data-theme="dark"] .paper-button--ghost { + background: rgb(var(--paper-100)); + border-color: rgb(var(--paper-200)); +} + +:root[data-theme="dark"] .nav-link:hover { + border-color: rgb(var(--paper-200)); + background: rgb(var(--paper-100)); +} + +:root[data-theme="dark"] .paper-button { + border-color: rgb(var(--forest-500) / 0.55); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); + box-shadow: none; +} + +:root[data-theme="dark"] .paper-button:hover, +:root[data-theme="dark"] .paper-button:focus-visible { + border-color: rgb(var(--forest-500) / 0.75); + background: rgb(var(--paper-50)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .wood-nav .paper-button, +:root[data-theme="dark"] .wood-nav .paper-button:hover, +:root[data-theme="dark"] .wood-nav .paper-button:focus-visible { + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .nav-link--active { + border-color: rgb(var(--forest-500) / 0.55); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .status-pill--success { + border-color: rgb(var(--forest-500) / 0.45); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .alert--success { + border-color: rgb(var(--forest-500) / 0.45); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .paper-button--ghost:hover { + background: rgb(var(--paper-50)); + border-color: rgb(var(--forest-500) / 0.5); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .paper-button--ghost[aria-pressed="true"] { + border-color: rgb(var(--forest-500) / 0.58); + background: rgb(var(--paper-50)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .zen-user-popover-card { + border-color: rgb(var(--paper-200)); + background: rgb(var(--paper-50)); + box-shadow: + 0 1px 0 rgb(255 255 255 / 0.05), + 0 22px 42px -24px rgb(0 0 0 / 0.85); +} + +:root[data-theme="dark"] .zen-user-preview-name { + color: rgb(var(--ink-900)); +} + +:root[data-theme="dark"] .zen-user-preview-email { + color: rgb(var(--ink-700)); +} + +:root[data-theme="dark"] .zen-user-popover-actions, +:root[data-theme="dark"] .zen-user-popover-footer { + border-top-color: rgb(var(--paper-200)); +} + +:root[data-theme="dark"] .zen-user-popover-action { + color: rgb(var(--ink-900)); +} + +:root[data-theme="dark"] .zen-user-popover-action:hover, +:root[data-theme="dark"] .zen-user-popover-action:focus-visible { + background: rgb(var(--paper-100)); + color: rgb(var(--ink-900)); +} + +:root[data-theme="dark"] .zen-user-popover-action-iconbox, +:root[data-theme="dark"] .zen-user-popover-action-icon { + color: rgb(var(--ink-700)); +} + +:root[data-theme="dark"] .donate-fab { + border-color: rgb(var(--forest-500) / 0.8); + background: rgb(var(--forest-600)); + box-shadow: + 0 22px 34px -16px rgb(0 0 0 / 0.9), + inset 0 1px 0 rgb(255 255 255 / 0.2); +} + +:root[data-theme="dark"] .donate-fab:hover { + border-color: rgb(var(--leaf-300) / 0.65); + background: rgb(var(--forest-600)); + box-shadow: + 0 26px 38px -16px rgb(0 0 0 / 0.9), + 0 0 0 1px rgb(var(--leaf-300) / 0.3), + inset 0 1px 0 rgb(255 255 255 / 0.24); +} + +:root[data-theme="dark"] .donate-fab.donate-fab--with-image, +:root[data-theme="dark"] .donate-fab.donate-fab--with-image:hover { + background: transparent; + border-color: transparent; +} + +:root[data-theme="dark"] .donate-fab.donate-fab--with-image { + box-shadow: + 0 0 0 1px rgb(255 255 255 / 0.06), + 0 10px 20px -14px rgb(0 0 0 / 0.86), + 0 6px 22px -16px rgb(214 224 234 / 0.35); +} + +:root[data-theme="dark"] .donate-fab.donate-fab--with-image:hover { box-shadow: - 0 16px 28px -22px rgba(60, 40, 22, 0.6), - inset 0 1px 0 rgba(255, 255, 255, 0.7); + 0 0 0 1px rgb(255 255 255 / 0.1), + 0 14px 26px -12px rgb(0 0 0 / 0.9), + 0 10px 28px -14px rgb(214 224 234 / 0.5); +} + +:root[data-theme="dark"] .donate-fab-icon-image { + filter: + drop-shadow(0 0 1px rgb(240 247 255 / 0.28)) + drop-shadow(0 10px 16px rgb(0 0 0 / 0.56)) + drop-shadow(0 0 16px rgb(214 224 234 / 0.28)); +} + +:root[data-theme="dark"] .donate-fab.donate-fab--with-image:hover .donate-fab-icon-image { + filter: + drop-shadow(0 0 1px rgb(245 251 255 / 0.42)) + drop-shadow(0 14px 24px rgb(0 0 0 / 0.7)) + drop-shadow(0 0 26px rgb(214 224 234 / 0.46)); +} + +:root[data-theme="dark"] .donate-fab-hint { + border-color: rgb(var(--paper-200)); + background: rgb(var(--paper-50)); + color: rgb(var(--ink-900)); +} + +:root[data-theme="dark"] .donate-fab-hint::after { + background: rgb(var(--paper-50)); + border-right-color: rgb(var(--paper-200)); + border-bottom-color: rgb(var(--paper-200)); +} + +:root[data-theme="dark"] .donate-card-frame { + background: rgb(var(--paper-100)); +} + +:root[data-theme="dark"] .status-pill--warning { + border-color: rgb(251 191 36 / 0.45); + color: rgb(253 230 138); +} + +:root[data-theme="dark"] .status-pill--error { + border-color: rgb(248 113 113 / 0.45); + color: rgb(254 202 202); +} + +:root[data-theme="dark"] .alert--error { + border-color: rgb(248 113 113 / 0.45); + color: rgb(254 202 202); +} + +:root[data-theme="dark"] .paper-button--danger { + border-color: rgb(var(--rose-200) / 0.8); + background: rgb(var(--rose-100)); + color: rgb(254 202 202); +} + +:root[data-theme="dark"] .paper-button--danger:hover { + background: rgb(var(--rose-200)); } .fade-up { - animation: fade-up 0.75s ease both; + animation: fade-up 0.55s ease both; } @keyframes fade-up { from { opacity: 0; - transform: translateY(16px); + transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + +@media (max-width: 640px) { + .paper-card, + .paper-stack { + border-radius: 14px; + } + + .paper-button, + .paper-button--ghost, + .paper-button--danger { + width: 100%; + } + + .theme-toggle, + .theme-toggle.paper-button--ghost { + width: 2rem; + min-width: 2rem; + } + + .donate-fab { + right: 0.9rem; + bottom: 0.9rem; + width: 4.4rem; + height: 4.4rem; + border-radius: 1rem; + } + + .donate-close-button { + width: auto; + } + + .donate-fab-hint { + display: none; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index abb8417..4044f22 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,16 +1,18 @@ import type { Metadata } from "next"; -import { Playfair_Display, Source_Serif_4 } from "next/font/google"; +import { Manrope, Public_Sans } from "next/font/google"; + +import DonateBookmark from "@/components/DonateBookmark"; import Providers from "./providers"; import "./globals.css"; -const displayFont = Playfair_Display({ +const displayFont = Manrope({ variable: "--font-display", subsets: ["latin"], - weight: ["600", "700"], + weight: ["600", "700", "800"], }); -const bodyFont = Source_Serif_4({ +const bodyFont = Public_Sans({ variable: "--font-body", subsets: ["latin"], weight: ["400", "500", "600"], @@ -19,18 +21,44 @@ const bodyFont = Source_Serif_4({ export const metadata: Metadata = { title: "ZenPDF", description: - "ZenPDF is an open-source PDF workbench with clear usage limits and a tactile dossier-style interface.", + "ZenPDF is an open-source PDF workbench with clear usage limits and a clean, readable interface.", }; +const themeInitScript = ` +(() => { + try { + const key = "zenpdf-theme"; + const saved = window.localStorage.getItem(key); + const theme = saved === "light" || saved === "dark" + ? saved + : "light"; + document.documentElement.dataset.theme = theme; + } catch (_) { + document.documentElement.dataset.theme = "light"; + } +})(); +`; + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - - - {children} + + + +