From 130cbcc952a9b2fb24a388dede8f2518dfa60562 Mon Sep 17 00:00:00 2001 From: Pixelated Date: Sat, 27 Jun 2026 16:23:12 -0600 Subject: [PATCH 1/6] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1655c54..e422fa3 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "eslint-config-next": "^15.4.4", "husky": "^8.0.3", "lint-staged": "^15.5.2", - "postcss": "^8.5.6", + "postcss": "^8.5.10", "postcss-load-config": "^6.0.1", "prettier": "^3.8.1", "tailwindcss": "^3.4.17", From a11f0355a7d9640201d1452bd5f5f198b4b2163c Mon Sep 17 00:00:00 2001 From: Pixelated Date: Sat, 27 Jun 2026 16:26:37 -0600 Subject: [PATCH 2/6] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e422fa3..21a8c69 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.13", "@types/react-dom": "^18.3.7", - "@types/uuid": "^10.0.0", + "@types/uuid": "^11.1.1", "babel-plugin-react-compiler": "^1.0.0", "commitlint": "^21.0.1", "eslint": "^9.37.0", From 475dd9625e0b6a92c9f55a36e4feea1e934f6f58 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 26 Jul 2026 16:30:04 -0600 Subject: [PATCH 3/6] fix(api): updated some inconsistencies --- CHANGELOG.md | 12 + app/api/analytics/top-users/route.ts | 265 +++++++++++------- app/api/files/[id]/thumbnail/route.ts | 2 +- .../files/chunks/[uploadId]/complete/route.ts | 1 + app/api/files/route.ts | 1 + package.json | 2 +- packages/lib/auth/api-auth.ts | 82 ++++-- packages/types/dto/file.ts | 3 +- 8 files changed, 241 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 999bdbc..775ec31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on "Keep a Changelog" and follows [Semantic Versioning](https://semver.org/). +## [2.5.1] - 2026-07-26 + +### Fixed + +- **System/admin API keys rejected on protected routes** — `requireAdmin()`, `requireSuperAdmin()`, `requireRole()`, and `requirePermission()` (`packages/lib/auth/api-auth.ts`) take an optional `req` parameter used to check the `esk_…` system key via the Bearer token, but the overwhelming majority of call sites (54 for `requireAdmin()` alone, all 8 for `requireSuperAdmin()`) never passed it — so the system-key check silently never ran on those routes and a valid system key got "Unauthorized". Fixed by falling back to Next.js's request-scoped `headers()` when `req` is omitted, so the check now runs regardless of call site. +- **Admin API keys had no effect on admin-gated routes** — the same four helpers only ever checked the browser session or the system key, never a personal `ebk_…` API key belonging to an actual admin/superadmin user. Added a `resolveActingUser()` layer that checks session → personal API key/upload token → system key, in that order, so an admin's own key now works on admin routes, not just the system key. +- **SUPERADMIN excluded from two admin routes** — `app/api/analytics/top-users/route.ts` and `app/api/files/[id]/thumbnail/route.ts` both gated access with a strict `role === 'ADMIN'` check, so a SUPERADMIN got 403/404 where an ADMIN would succeed. Both now accept either role. + +### Added + +- `FileUploadResponse` (`packages/types/dto/file.ts`) now includes the file's database `id`, returned from both `POST /api/files` and the chunked-upload completion endpoint. Previously an API client had no way to reference a file it had just uploaded for a later update/delete call. + ## [2.5.0] - 2026-06-25 ### Added diff --git a/app/api/analytics/top-users/route.ts b/app/api/analytics/top-users/route.ts index 1009367..810abd7 100644 --- a/app/api/analytics/top-users/route.ts +++ b/app/api/analytics/top-users/route.ts @@ -3,113 +3,188 @@ import { requireAuth } from '@/packages/lib/auth/api-auth' import { prisma } from '@/packages/lib/database/prisma' export async function GET(req: Request) { - try { - const { user, response } = await requireAuth(req) + try { + const { user, response } = await requireAuth(req) if (response) return response - // ensure we know the user's role - const isAdmin = user.role === 'ADMIN' - if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + // ensure we know the user's role + const isAdmin = user.role === 'ADMIN' || user.role === 'SUPERADMIN' + if (!isAdmin) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - // aggregate downloads per user from files (include all users so scoring is accurate) - const downloads = await prisma.file.groupBy({ by: ['userId'], _sum: { downloads: true } }) - const clicks = await prisma.shortenedUrl.groupBy({ by: ['userId'], _sum: { clicks: true } }) - const fileCounts = await prisma.file.groupBy({ by: ['userId'], _count: { _all: true } }) + // aggregate downloads per user from files (include all users so scoring is accurate) + const downloads = await prisma.file.groupBy({ + by: ['userId'], + _sum: { downloads: true }, + }) + const clicks = await prisma.shortenedUrl.groupBy({ + by: ['userId'], + _sum: { clicks: true }, + }) + const fileCounts = await prisma.file.groupBy({ + by: ['userId'], + _count: { _all: true }, + }) - // merge by userId - const map = new Map() - downloads.forEach((d) => map.set(d.userId, { downloads: d._sum.downloads ?? 0, clicks: 0, filesCount: 0 })) - clicks.forEach((c) => { - const cur = map.get(c.userId) || { downloads: 0, clicks: 0, filesCount: 0 } - cur.clicks = c._sum.clicks ?? 0 - map.set(c.userId, cur) - }) - fileCounts.forEach((f) => { - const cur = map.get(f.userId) || { downloads: 0, clicks: 0, filesCount: 0 } - cur.filesCount = f._count._all ?? 0 - map.set(f.userId, cur) - }) + // merge by userId + const map = new Map< + string, + { downloads: number; clicks: number; filesCount: number } + >() + downloads.forEach((d) => + map.set(d.userId, { + downloads: d._sum.downloads ?? 0, + clicks: 0, + filesCount: 0, + }) + ) + clicks.forEach((c) => { + const cur = map.get(c.userId) || { + downloads: 0, + clicks: 0, + filesCount: 0, + } + cur.clicks = c._sum.clicks ?? 0 + map.set(c.userId, cur) + }) + fileCounts.forEach((f) => { + const cur = map.get(f.userId) || { + downloads: 0, + clicks: 0, + filesCount: 0, + } + cur.filesCount = f._count._all ?? 0 + map.set(f.userId, cur) + }) - // compute primary score (popularity) and a composite score that slightly rewards file count - const FILE_COUNT_WEIGHT = 0.2 - const items = Array.from(map.entries()).map(([userId, v]) => { - const downloads = Number(v.downloads || 0) - const clicks = Number(v.clicks || 0) - const filesCount = Number(v.filesCount || 0) - const primaryScore = downloads + clicks - const compositeScore = primaryScore + filesCount * FILE_COUNT_WEIGHT - return { userId, downloads, clicks, filesCount, primaryScore, compositeScore } - }) - // sort by composite score descending (break ties by primaryScore, then filesCount) - items.sort((a, b) => { - if (b.compositeScore !== a.compositeScore) return b.compositeScore - a.compositeScore - if (b.primaryScore !== a.primaryScore) return b.primaryScore - a.primaryScore - return b.filesCount - a.filesCount - }) - const top = items.slice(0, 10) + // compute primary score (popularity) and a composite score that slightly rewards file count + const FILE_COUNT_WEIGHT = 0.2 + const items = Array.from(map.entries()).map(([userId, v]) => { + const downloads = Number(v.downloads || 0) + const clicks = Number(v.clicks || 0) + const filesCount = Number(v.filesCount || 0) + const primaryScore = downloads + clicks + const compositeScore = primaryScore + filesCount * FILE_COUNT_WEIGHT + return { + userId, + downloads, + clicks, + filesCount, + primaryScore, + compositeScore, + } + }) + // sort by composite score descending (break ties by primaryScore, then filesCount) + items.sort((a, b) => { + if (b.compositeScore !== a.compositeScore) + return b.compositeScore - a.compositeScore + if (b.primaryScore !== a.primaryScore) + return b.primaryScore - a.primaryScore + return b.filesCount - a.filesCount + }) + const top = items.slice(0, 10) - const userIds = top.map((t) => t.userId) - const users = await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, image: true, email: true } }) - const usersById = new Map(users.map((u) => [u.id, u])) + const userIds = top.map((t) => t.userId) + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, image: true, email: true }, + }) + const usersById = new Map(users.map((u) => [u.id, u])) - // prepare enriched items with avgPerFile and include both primary and composite scores - const enriched = top.map((t) => ({ - userId: t.userId, - downloads: t.downloads, - clicks: t.clicks, - filesCount: t.filesCount || 0, - primaryScore: t.primaryScore, - compositeScore: t.compositeScore, - avgPerFile: t.filesCount ? (t.primaryScore / t.filesCount) : t.primaryScore, - })) + // prepare enriched items with avgPerFile and include both primary and composite scores + const enriched = top.map((t) => ({ + userId: t.userId, + downloads: t.downloads, + clicks: t.clicks, + filesCount: t.filesCount || 0, + primaryScore: t.primaryScore, + compositeScore: t.compositeScore, + avgPerFile: t.filesCount ? t.primaryScore / t.filesCount : t.primaryScore, + })) - // prepare totals and me info for everyone (admins also get these) - const totalUsers = items.length + // prepare totals and me info for everyone (admins also get these) + const totalUsers = items.length - const currentUserId: string | undefined = user.id + const currentUserId: string | undefined = user.id - const meEntry = items.find((it) => it.userId === currentUserId) || { userId: currentUserId || 'unknown', downloads: 0, clicks: 0, filesCount: 0, primaryScore: 0, compositeScore: 0 } - // rank based on compositeScore (the ranking users see) - const rank = items.filter((it) => it.compositeScore > meEntry.compositeScore).length + 1 - const me = { - userId: meEntry.userId, - downloads: meEntry.downloads || 0, - clicks: meEntry.clicks || 0, - filesCount: meEntry.filesCount || 0, - primaryScore: meEntry.primaryScore || 0, - compositeScore: meEntry.compositeScore || 0, - avgPerFile: meEntry.filesCount ? (meEntry.primaryScore / meEntry.filesCount) : meEntry.primaryScore, - } - - // build anonymized distribution (deciles) for privacy - const bucketsCount = 10 - const groupSize = Math.max(1, Math.ceil(items.length / bucketsCount)) - const buckets: Array<{ label: string; count: number; avgScore: number }> = [] - for (let i = 0; i < bucketsCount; i++) { - const start = i * groupSize - const end = Math.min(start + groupSize, items.length) - const slice = items.slice(start, end) - const count = slice.length - const avgScore = count > 0 ? Math.round(slice.reduce((s, x) => s + x.compositeScore, 0) / count) : 0 - const label = `${i * 10 + 1}-${Math.min((i + 1) * 10, 100)}%` - buckets.push({ label, count, avgScore }) - } + const meEntry = items.find((it) => it.userId === currentUserId) || { + userId: currentUserId || 'unknown', + downloads: 0, + clicks: 0, + filesCount: 0, + primaryScore: 0, + compositeScore: 0, + } + // rank based on compositeScore (the ranking users see) + const rank = + items.filter((it) => it.compositeScore > meEntry.compositeScore).length + + 1 + const me = { + userId: meEntry.userId, + downloads: meEntry.downloads || 0, + clicks: meEntry.clicks || 0, + filesCount: meEntry.filesCount || 0, + primaryScore: meEntry.primaryScore || 0, + compositeScore: meEntry.compositeScore || 0, + avgPerFile: meEntry.filesCount + ? meEntry.primaryScore / meEntry.filesCount + : meEntry.primaryScore, + } - let userBucketIndex = -1 - const myIndex = items.findIndex((it) => it.userId === currentUserId) - if (myIndex >= 0) userBucketIndex = Math.floor(myIndex / groupSize) + // build anonymized distribution (deciles) for privacy + const bucketsCount = 10 + const groupSize = Math.max(1, Math.ceil(items.length / bucketsCount)) + const buckets: Array<{ label: string; count: number; avgScore: number }> = + [] + for (let i = 0; i < bucketsCount; i++) { + const start = i * groupSize + const end = Math.min(start + groupSize, items.length) + const slice = items.slice(start, end) + const count = slice.length + const avgScore = + count > 0 + ? Math.round(slice.reduce((s, x) => s + x.compositeScore, 0) / count) + : 0 + const label = `${i * 10 + 1}-${Math.min((i + 1) * 10, 100)}%` + buckets.push({ label, count, avgScore }) + } - // if the requester is admin, return the full top users list (with avgPerFile) plus metadata - if (isAdmin) { - const result = enriched.map((t) => ({ user: usersById.get(t.userId) || { id: t.userId }, downloads: t.downloads, clicks: t.clicks, filesCount: t.filesCount, primaryScore: t.primaryScore, compositeScore: t.compositeScore, avgPerFile: t.avgPerFile })) - return NextResponse.json({ topUsers: result, me, rank, totalUsers, distribution: { buckets, userBucketIndex } }) - } + let userBucketIndex = -1 + const myIndex = items.findIndex((it) => it.userId === currentUserId) + if (myIndex >= 0) userBucketIndex = Math.floor(myIndex / groupSize) - // For non-admins, return the requesting user's stats and the anonymized distribution - return NextResponse.json({ me, rank, totalUsers, distribution: { buckets, userBucketIndex } }) - } catch (err) { - console.error('analytics/top-users error', err) - return NextResponse.json({ error: 'Failed to fetch top users' }, { status: 500 }) + // if the requester is admin, return the full top users list (with avgPerFile) plus metadata + if (isAdmin) { + const result = enriched.map((t) => ({ + user: usersById.get(t.userId) || { id: t.userId }, + downloads: t.downloads, + clicks: t.clicks, + filesCount: t.filesCount, + primaryScore: t.primaryScore, + compositeScore: t.compositeScore, + avgPerFile: t.avgPerFile, + })) + return NextResponse.json({ + topUsers: result, + me, + rank, + totalUsers, + distribution: { buckets, userBucketIndex }, + }) } -} + // For non-admins, return the requesting user's stats and the anonymized distribution + return NextResponse.json({ + me, + rank, + totalUsers, + distribution: { buckets, userBucketIndex }, + }) + } catch (err) { + console.error('analytics/top-users error', err) + return NextResponse.json( + { error: 'Failed to fetch top users' }, + { status: 500 } + ) + } +} diff --git a/app/api/files/[id]/thumbnail/route.ts b/app/api/files/[id]/thumbnail/route.ts index c3b23e5..060b946 100644 --- a/app/api/files/[id]/thumbnail/route.ts +++ b/app/api/files/[id]/thumbnail/route.ts @@ -55,7 +55,7 @@ export async function GET( // Check access permissions (similar to main file endpoint) const isOwner = user?.id === file.userId - const isAdmin = user?.role === 'ADMIN' + const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPERADMIN' const isPrivate = file.visibility === 'PRIVATE' && !isOwner && !isAdmin if (isPrivate) { diff --git a/app/api/files/chunks/[uploadId]/complete/route.ts b/app/api/files/chunks/[uploadId]/complete/route.ts index d572b4b..329eefa 100644 --- a/app/api/files/chunks/[uploadId]/complete/route.ts +++ b/app/api/files/chunks/[uploadId]/complete/route.ts @@ -335,6 +335,7 @@ export async function POST( } const responseData: FileUploadResponse = { + id: fileRecord.id, url: `${finalFullUrl}${metadata.urlPath}/`, name: metadata.filename, size: metadata.totalSize, diff --git a/app/api/files/route.ts b/app/api/files/route.ts index 05155c1..94d2dd6 100644 --- a/app/api/files/route.ts +++ b/app/api/files/route.ts @@ -415,6 +415,7 @@ export async function POST(req: Request) { } const responseData: FileUploadResponse = { + id: fileRecord.id, url: `${finalFullUrl}${urlPath}/`, name: displayName, size: uploadedFile.size, diff --git a/package.json b/package.json index 1655c54..db1d45b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@emberly/website", - "version": "2.5.0", + "version": "2.5.1", "license": "AGPL-3.0-only", "author": "CodeMeAPixel", "homepage": "https://github.com/EmberlyOSS/Emberly", diff --git a/packages/lib/auth/api-auth.ts b/packages/lib/auth/api-auth.ts index ee198c5..830865e 100644 --- a/packages/lib/auth/api-auth.ts +++ b/packages/lib/auth/api-auth.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { headers } from 'next/headers' import { authOptions } from '@/packages/lib/auth' import { sessionCache } from '@/packages/lib/cache/session-cache' @@ -117,7 +118,7 @@ export async function requireSquadAuth(req: Request) { } export async function getAuthenticatedUser( - req: Request + req?: Request ): Promise { const session = await getServerSession(authOptions) if (session?.user) { @@ -177,7 +178,7 @@ export async function getAuthenticatedUser( return user ? { ...user, emailVerified: !!user.emailVerified } : null } - const authHeader = req.headers.get('authorization') + const authHeader = await resolveAuthHeader(req) if (authHeader?.startsWith('Bearer ')) { const token = authHeader.substring(7) @@ -284,7 +285,7 @@ export async function getAuthenticatedUser( return null } -export async function requireAuth(req: Request) { +export async function requireAuth(req?: Request) { const user = await getAuthenticatedUser(req) if (!user) { return { @@ -307,22 +308,34 @@ const SYSTEM_KEY_USER = { * Returns a synthetic SUPERADMIN user object on success, null otherwise. */ async function getSystemKeyUser(req?: Request) { - if (!req) return null const valid = await isSystemKeyAuth(req) return valid ? SYSTEM_KEY_USER : null } -export async function requireAdmin(req?: Request) { +/** + * Resolve the "acting user" for a permission check across all three + * supported auth methods: browser session, personal/squad Bearer token + * (upload token or `ebk_…` API key), or the `esk_…` system key. The system + * key always satisfies any permission check (it's a SUPERADMIN identity); + * the other two are checked by the caller against the actual permission. + */ +async function resolveActingUser(req?: Request) { const session = await getServerSession(authOptions) + if (session?.user) return session.user - if ( - session?.user && - hasPermission(session.user.role as any, Permission.ACCESS_ADMIN_PANEL) - ) { - return { user: session.user, response: null } + const apiUser = await getAuthenticatedUser(req) + if (apiUser) return apiUser + + return null +} + +export async function requireAdmin(req?: Request) { + const user = await resolveActingUser(req) + if (user && hasPermission(user.role as any, Permission.ACCESS_ADMIN_PANEL)) { + return { user, response: null } } - // Fall back to system API key + // Fall back to system API key (always satisfies any permission) const systemUser = await getSystemKeyUser(req) if (systemUser) return { user: systemUser, response: null } @@ -333,16 +346,12 @@ export async function requireAdmin(req?: Request) { } export async function requireSuperAdmin(req?: Request) { - const session = await getServerSession(authOptions) - + const user = await resolveActingUser(req) if ( - session?.user && - hasPermission( - session.user.role as any, - Permission.PERFORM_SUPERADMIN_ACTIONS - ) + user && + hasPermission(user.role as any, Permission.PERFORM_SUPERADMIN_ACTIONS) ) { - return { user: session.user, response: null } + return { user, response: null } } // Fall back to system API key @@ -367,14 +376,14 @@ export async function requireRole( minRole: 'USER' | 'ADMIN' | 'SUPERADMIN', req?: Request ) { - const session = await getServerSession(authOptions) + const user = await resolveActingUser(req) - if (session?.user) { - const userRole = session.user.role as 'USER' | 'ADMIN' | 'SUPERADMIN' + if (user) { + const userRole = user.role as 'USER' | 'ADMIN' | 'SUPERADMIN' const roleHierarchy = { USER: 0, ADMIN: 10, SUPERADMIN: 100 } if (roleHierarchy[userRole] >= roleHierarchy[minRole]) { - return { user: session.user, response: null } + return { user, response: null } } return { @@ -402,10 +411,10 @@ export async function requireRole( * if (response) return response */ export async function requirePermission(permission: Permission, req?: Request) { - const session = await getServerSession(authOptions) + const user = await resolveActingUser(req) - if (session?.user && hasPermission(session.user.role as any, permission)) { - return { user: session.user, response: null } + if (user && hasPermission(user.role as any, permission)) { + return { user, response: null } } // System API key has all permissions @@ -605,13 +614,28 @@ export async function requireSquadPermission( // ── System API key authentication ────────────────────────────────────────── +/** + * Resolve the Authorization header either from an explicitly passed `Request` + * (e.g. in tests, or handlers with a non-standard signature) or, when omitted, + * from Next.js's request-scoped `headers()` — which works in any Route + * Handler regardless of whether the caller threaded `req` through. Most + * `requireAdmin()`/`requireSuperAdmin()` call sites in this codebase call + * these with no arguments, so this fallback is what makes system API keys + * actually work on those routes. + */ +async function resolveAuthHeader(req?: Request): Promise { + if (req) return req.headers.get('authorization') + const headerList = await headers() + return headerList.get('authorization') +} + /** * Verify a system API key from a Bearer token (esk_…). * The key hash is stored in the Config table under key 'system_api_key'. * Returns true if the token matches, false otherwise. */ -export async function isSystemKeyAuth(req: Request): Promise { - const authHeader = req.headers.get('authorization') +export async function isSystemKeyAuth(req?: Request): Promise { + const authHeader = await resolveAuthHeader(req) if (!authHeader?.startsWith('Bearer esk_')) return false const token = authHeader.substring(7) @@ -629,7 +653,7 @@ export async function isSystemKeyAuth(req: Request): Promise { /** * Require a valid system API key. Returns a 401 response if invalid. */ -export async function requireSystemKey(req: Request) { +export async function requireSystemKey(req?: Request) { const valid = await isSystemKeyAuth(req) if (!valid) { return { diff --git a/packages/types/dto/file.ts b/packages/types/dto/file.ts index 5e47d27..6b81413 100644 --- a/packages/types/dto/file.ts +++ b/packages/types/dto/file.ts @@ -15,7 +15,7 @@ export type FileUploadRequest = z.infer const FileLikeSchema = (() => { const globalFile = typeof globalThis !== 'undefined' && - typeof (globalThis as { File?: typeof File }).File !== 'undefined' + typeof (globalThis as { File?: typeof File }).File !== 'undefined' ? (globalThis as { File: typeof File }).File : null @@ -55,6 +55,7 @@ export interface FileMetadata { } export interface FileUploadResponse { + id: string url: string name: string size: number From 243bbab8d86ad68eaf930a85249fab79aa5a2b8d Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 26 Jul 2026 16:40:19 -0600 Subject: [PATCH 4/6] fix(deps): uuid typings version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ceb2e10..745d73d 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.13", "@types/react-dom": "^18.3.7", - "@types/uuid": "^11.1.1", + "@types/uuid": "^11.0.0", "babel-plugin-react-compiler": "^1.0.0", "commitlint": "^21.0.1", "eslint": "^9.37.0", From b8ff45f5763be41e0b5f3f8cd1805cef3a402402 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Mon, 27 Jul 2026 03:53:31 -0600 Subject: [PATCH 5/6] feat(add): better self hosting support --- .env.template | 3 - .gitignore | 1 + AGENTS.md | 27 +- CLAUDE.md | 3 +- app/(main)/about/page.tsx | 421 ----- app/(main)/admin/email/page.tsx | 49 +- app/(main)/admin/overview-client.tsx | 126 +- app/(main)/admin/page.tsx | 26 +- app/(main)/admin/products/page.tsx | 24 +- app/(main)/admin/reports/page.tsx | 604 +++---- app/(main)/applications/[type]/page.tsx | 70 - app/(main)/applications/page.tsx | 160 -- app/(main)/changelogs/page.tsx | 35 - app/(main)/contact/page.tsx | 297 ---- app/(main)/dashboard/bucket/page.tsx | 3 + app/(main)/dashboard/client.tsx | 11 +- app/(main)/dashboard/discovery/page.tsx | 69 +- app/(main)/dashboard/domains/page.tsx | 8 +- app/(main)/dashboard/squads/[id]/page.tsx | 15 +- app/(main)/dashboard/squads/page.tsx | 9 +- app/(main)/error.tsx | 52 +- app/(main)/legal/[...slug]/page.tsx | 53 - app/(main)/me/page.tsx | 162 +- app/(main)/not-found.tsx | 335 ++-- app/(main)/press/media-kit/layout.tsx | 10 - app/(main)/press/media-kit/page.tsx | 594 ------- app/(main)/press/page.tsx | 327 ---- app/(main)/pricing/layout.tsx | 3 - app/(main)/setup/page.tsx | 151 +- app/(marketing)/about/page.tsx | 470 ++++++ app/(marketing)/applications/[type]/page.tsx | 80 + app/(marketing)/applications/page.tsx | 173 ++ .../blog/[slug]/page.tsx | 0 app/{(main) => (marketing)}/blog/page.tsx | 0 app/(marketing)/changelogs/page.tsx | 46 + app/(marketing)/contact/page.tsx | 329 ++++ app/{(main) => (marketing)}/discord/page.tsx | 5 +- .../discovery/page.tsx | 91 +- app/(marketing)/layout.tsx | 31 + .../leaderboard/page.tsx | 6 +- app/(marketing)/legal/[...slug]/page.tsx | 62 + app/{(main) => (marketing)}/legal/page.tsx | 34 +- app/{(main) => (marketing)}/page.tsx | 157 +- app/(marketing)/press/media-kit/layout.tsx | 15 + app/(marketing)/press/media-kit/page.tsx | 680 ++++++++ app/(marketing)/press/page.tsx | 360 ++++ app/(marketing)/pricing/layout.tsx | 7 + app/{(main) => (marketing)}/pricing/page.tsx | 0 app/api/health/route.ts | 168 +- app/api/updates/check/route.ts | 3 +- app/api/users/[id]/contributions/route.ts | 6 + app/error.tsx | 57 +- app/layout.tsx | 46 +- app/not-found.tsx | 332 ++-- packages/components/admin/admin-sidebar.tsx | 60 +- .../admin/settings/settings-manager.tsx | 1468 +++++++++-------- packages/components/admin/system-health.tsx | 214 +++ .../components/dashboard/dashboard-shell.tsx | 30 +- .../dashboard/dashboard-sidebar.tsx | 181 +- packages/components/dashboard/user-list.tsx | 213 +-- packages/components/layout/footer.tsx | 30 +- packages/components/layout/nav.tsx | 337 ++-- packages/components/profile/index.tsx | 661 ++++---- packages/hooks/use-data-export.ts | 1 - packages/hooks/use-user-management.ts | 4 - packages/lib/config/env.ts | 52 +- packages/lib/config/index.ts | 4 + packages/lib/constants/site.ts | 1 + packages/lib/embeds/metadata.ts | 13 +- packages/lib/events/init.ts | 40 +- packages/lib/middleware/constants.ts | 58 + proxy.ts | 47 + 72 files changed, 6010 insertions(+), 4210 deletions(-) delete mode 100644 app/(main)/about/page.tsx delete mode 100644 app/(main)/applications/[type]/page.tsx delete mode 100644 app/(main)/applications/page.tsx delete mode 100644 app/(main)/changelogs/page.tsx delete mode 100644 app/(main)/contact/page.tsx delete mode 100644 app/(main)/legal/[...slug]/page.tsx delete mode 100644 app/(main)/press/media-kit/layout.tsx delete mode 100644 app/(main)/press/media-kit/page.tsx delete mode 100644 app/(main)/press/page.tsx delete mode 100644 app/(main)/pricing/layout.tsx create mode 100644 app/(marketing)/about/page.tsx create mode 100644 app/(marketing)/applications/[type]/page.tsx create mode 100644 app/(marketing)/applications/page.tsx rename app/{(main) => (marketing)}/blog/[slug]/page.tsx (100%) rename app/{(main) => (marketing)}/blog/page.tsx (100%) create mode 100644 app/(marketing)/changelogs/page.tsx create mode 100644 app/(marketing)/contact/page.tsx rename app/{(main) => (marketing)}/discord/page.tsx (98%) rename app/{(main) => (marketing)}/discovery/page.tsx (82%) create mode 100644 app/(marketing)/layout.tsx rename app/{(main) => (marketing)}/leaderboard/page.tsx (72%) create mode 100644 app/(marketing)/legal/[...slug]/page.tsx rename app/{(main) => (marketing)}/legal/page.tsx (69%) rename app/{(main) => (marketing)}/page.tsx (78%) create mode 100644 app/(marketing)/press/media-kit/layout.tsx create mode 100644 app/(marketing)/press/media-kit/page.tsx create mode 100644 app/(marketing)/press/page.tsx create mode 100644 app/(marketing)/pricing/layout.tsx rename app/{(main) => (marketing)}/pricing/page.tsx (100%) create mode 100644 packages/components/admin/system-health.tsx create mode 100644 packages/lib/constants/site.ts diff --git a/.env.template b/.env.template index 18365ab..f6d414b 100644 --- a/.env.template +++ b/.env.template @@ -1,6 +1,3 @@ -# Cloud instance (includes marketing pages) -EMBERLY_RUN_CLOUD=true - # Event Worker Configuration EMBERLY_RUN_EVENT_WORKER=true diff --git a/.gitignore b/.gitignore index 871d5ec..64f1859 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ yarn.lock bun.lock bun.lockb tmp/TODO.md +.claude/ # testing /coverage diff --git a/AGENTS.md b/AGENTS.md index 7283800..ea843fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,19 +253,20 @@ Redis is optional for local development but required in production. Copy `.env.template` → `.env` to get started. Critical variables: -| Variable | Purpose | -| --------------------------------------- | ---------------------------------- | -| `DATABASE_URL` | PostgreSQL connection string | -| `REDIS_URL` | Redis connection string | -| `NEXTAUTH_SECRET` | Session signing key (≥32 chars) | -| `NEXTAUTH_URL` / `NEXT_PUBLIC_BASE_URL` | App base URL | -| `DISCORD_OAUTH_CLIENT_ID/SECRET` | Discord OAuth | -| `GITHUB_OAUTH_CLIENT_ID/SECRET` | GitHub OAuth | -| `VIRUSTOTAL_API_KEY` | Malware scanning for uploads | -| `VULTR_API_KEY` | S3 bucket provisioning | -| `NEXT_PUBLIC_SENTRY_DSN` | Client-side error tracking | -| `EMBERLY_RUN_CLOUD` | Enable cloud/marketing features | -| `EMBERLY_RUN_EVENT_WORKER` | Enable background event processing | +| Variable | Purpose | +| --------------------------------------- | ------------------------------------------------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | +| `REDIS_URL` | Redis connection string | +| `NEXTAUTH_SECRET` | Session signing key (≥32 chars) | +| `NEXTAUTH_URL` / `NEXT_PUBLIC_BASE_URL` | App base URL | +| `DISCORD_OAUTH_CLIENT_ID/SECRET` | Discord OAuth | +| `GITHUB_OAUTH_CLIENT_ID/SECRET` | GitHub OAuth | +| `VIRUSTOTAL_API_KEY` | Malware scanning for uploads | +| `VULTR_API_KEY` | S3 bucket provisioning | +| `NEXT_PUBLIC_SENTRY_DSN` | Client-side error tracking | +| `EMBERLY_RUN_CLOUD` | Enable cloud features (billing, Nexium, custom domains, marketing pages) | +| `NEXT_PUBLIC_EMBERLY_RUN_CLOUD` | Same value, mirrored for client-side nav — must match `EMBERLY_RUN_CLOUD` | +| `EMBERLY_RUN_EVENT_WORKER` | Enable background event processing | Never commit `.env`. Never log env vars. `SENTRY_AUTH_TOKEN` is build-time only (CI). diff --git a/CLAUDE.md b/CLAUDE.md index 7cc3eb7..4edaefb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,7 +227,8 @@ DISCORD_OAUTH_* # Discord OAuth app credentials GITHUB_OAUTH_* # GitHub OAuth app credentials VIRUSTOTAL_API_KEY # File scanning (optional in dev) NEXT_PUBLIC_SENTRY_DSN # Error tracking (optional in dev) -EMBERLY_RUN_CLOUD # Set true to enable cloud features +EMBERLY_RUN_CLOUD # Set true to enable cloud features (billing, Nexium, custom domains, marketing pages) +NEXT_PUBLIC_EMBERLY_RUN_CLOUD # Same value, mirrored for client-side nav/UI — must match EMBERLY_RUN_CLOUD EMBERLY_RUN_EVENT_WORKER # Set true to run the event worker ``` diff --git a/app/(main)/about/page.tsx b/app/(main)/about/page.tsx deleted file mode 100644 index a294129..0000000 --- a/app/(main)/about/page.tsx +++ /dev/null @@ -1,421 +0,0 @@ -import Link from 'next/link' -import { getServerSession } from 'next-auth/next' -import { authOptions } from '@/packages/lib/auth' - -import { - ArrowRight, - BookOpen, - Check, - Code2, - Github, - Globe, - Heart, - Lock, - Rocket, - Server, - Shield, - Sparkles, - Users, - Zap, -} from 'lucide-react' - -import { Avatar, AvatarFallback, AvatarImage } from '@/packages/components/ui/avatar' -import { Button } from '@/packages/components/ui/button' -import { Badge } from '@/packages/components/ui/badge' -import HomeShell from '@/packages/components/layout/home-shell' - -// Reusable GlassCard component -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -const CORE_VALUES = [ - { - icon: Shield, - title: 'Privacy First', - description: 'Your files, your control. We never scan, index, or monetize your content.', - color: 'text-chart-1', - bg: 'bg-chart-1/10', - }, - { - icon: Zap, - title: 'Lightning Fast', - description: 'Optimized upload flows with chunked transfers and edge caching.', - color: 'text-primary', - bg: 'bg-primary/10', - }, - { - icon: Code2, - title: 'Open Source', - description: 'Fully transparent codebase. Audit it, fork it, contribute to it.', - color: 'text-chart-4', - bg: 'bg-chart-4/10', - }, - { - icon: Server, - title: 'Self-Hostable', - description: 'Deploy on your own infrastructure with Docker or bare metal.', - color: 'text-chart-3', - bg: 'bg-chart-3/10', - }, -] - -const FEATURES = [ - { icon: Lock, label: 'Password Protection' }, - { icon: Globe, label: 'Custom Domains' }, - { icon: Rocket, label: 'Rich Embeds' }, - { icon: Users, label: 'Team Sharing' }, -] - -async function getContributors() { - const { getIntegrations } = await import('@/packages/lib/config') - const integrations = await getIntegrations() - const org = integrations.github?.org || process.env.GITHUB_ORG || 'EmberlyOSS' - const pat = integrations.github?.pat || process.env.GITHUB_PAT - const headers: Record = { - Accept: 'application/vnd.github.v3+json', - } - if (pat) { - headers.Authorization = `token ${pat}` - } - const excludedRepos = new Set( - (["premid", "activities", "statuspage", "peppermint"]) - .map((repo) => repo.trim().toLowerCase()) - .filter(Boolean) - ) - - try { - const [membersRes, reposRes] = await Promise.all([ - fetch(`https://api.github.com/orgs/${org}/members?per_page=100`, { - headers, - next: { revalidate: 60 * 60 }, - }), - fetch(`https://api.github.com/orgs/${org}/repos?per_page=100`, { - headers, - next: { revalidate: 60 * 60 }, - }), - ]) - - const membersJson = membersRes.ok ? await membersRes.json() : [] - const reposJson = reposRes.ok ? await reposRes.json() : [] - - const repoNames = Array.isArray(reposJson) - ? reposJson.map((r: any) => r.name) - .filter((name: any) => typeof name === 'string' && name.trim().length > 0) - .filter((name: string) => !excludedRepos.has(name.toLowerCase())) - : [] - - const contribPromises = repoNames.map((name: string) => - fetch(`https://api.github.com/repos/${org}/${name}/contributors?per_page=100`, { - headers, - next: { revalidate: 60 * 60 }, - }) - .then((r) => (r.ok ? r.json() : [])) - .catch(() => []) - ) - - const contribSettled = await Promise.allSettled(contribPromises) - - const map = new Map() - - if (Array.isArray(membersJson)) { - for (const m of membersJson) { - if (!m || !m.login) continue - map.set(m.login.toLowerCase(), { - login: m.login, - avatar: m.avatar_url, - url: m.html_url || `https://github.com/${m.login}`, - contributions: 0, - orgMember: true, - }) - } - } - - for (const s of contribSettled) { - if (s.status !== 'fulfilled') continue - const arr = s.value - if (!Array.isArray(arr)) continue - for (const c of arr) { - if (!c || !c.login) continue - const key = c.login.toLowerCase() - const contributions = typeof c.contributions === 'number' ? c.contributions : 0 - const existing = map.get(key) - if (existing) { - existing.contributions = (existing.contributions || 0) + contributions - existing.avatar = existing.avatar || c.avatar_url - existing.url = existing.url || c.html_url - map.set(key, existing) - } else { - map.set(key, { - login: c.login, - avatar: c.avatar_url, - url: c.html_url, - contributions, - orgMember: false, - }) - } - } - } - - const result = Array.from(map.values()).sort((a: any, b: any) => { - if (a.orgMember === b.orgMember) return (b.contributions || 0) - (a.contributions || 0) - return a.orgMember ? -1 : 1 - }) - - return result - } catch (e) { - console.error('Failed to load contributors', e) - return [] - } -} - -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' - -export const metadata = buildPageMetadata({ - title: 'About Us', - description: 'Emberly is an open source platform for file sharing, URL shortening, and talent discovery. Privacy-first, developer-friendly, and built for teams.', -}) - -export default async function AboutPage() { - const session = await getServerSession(authOptions) - const contributors = await getContributors() - - return ( - -
- {/* Hero Section */} - -
-
-
- - - Open Source - -

- More than hosting, - - it's a platform. - -

-

- Emberly started as a privacy-first file sharing tool for developers and has grown into - a full platform: file hosting, URL shortening, and Nexium our built in talent discovery and squad - collaboration network. Self-hostable, open source, and built for teams. -

- -
- - -
-
- - {/* Mission Card */} -
-
-
- -
-
-
- -
-

Our Mission

-
-

- Build and share more than just files. Emberly gives you the tools - to host content, shorten links, discover talent, and collaborate - with squads — all in one open source platform. -

-
- {[ - 'Fast uploads with chunked and presigned flows', - 'URL shortener with analytics and custom slugs', - 'Discovery: talent profiles, squads, and discovery', - ].map((item, i) => ( -
-
- -
- {item} -
- ))} -
-
-
-
-
-
-
- - - {/* Core Values */} -
- {CORE_VALUES.map((value) => ( - -
-
- -
-

{value.title}

-

- {value.description} -

-
-
- ))} -
- - {/* Why Emberly & Features */} -
- -
-

Why Emberly?

-

- We built Emberly because existing file sharing solutions are either too complex, - too expensive, or don't respect your privacy. Emberly prioritizes speed, privacy, - and a pleasant UX. It's built so teams can self-host without wrestling with complex - infrastructure. -

-

- As Emberly has grown, so has the scope. File hosting is the core — but now we also - offer URL shortening, rich embed previews, and Discovery: a talent discovery and squad - collaboration platform. Whether you're a developer, creator, or community builder, - Emberly scales with you while keeping your data under your control. -

-
- {FEATURES.map((feature) => ( -
- - {feature.label} -
- ))} -
-
-
- - -
-

Get Involved

-

- Contributions, bug reports, and ideas are welcome. Check the repo for issues - and contributor guidelines, or join our Discord to chat with the community. -

-
- - -
-
-
-
- - {/* Contributors Section */} - {contributors.length > 0 && ( - -
-
-
-

Contributors

-

- The amazing people who make Emberly possible -

-
- - {contributors.length} contributors - -
-
- {contributors.map((c) => ( - -
- - - - {c.login?.[0]?.toUpperCase() || 'U'} - - - {c.orgMember && ( -
- -
- )} - - ))} -
-

- - indicates organization member -

-
- - )} - - {/* CTA Section */} - -
-

Ready to get started?

-

- Join thousands of developers, creators, and teams who use Emberly to share, - build, and connect. -

-
- {session ? ( - - ) : ( - - )} - -
-
-
-
- - ) -} diff --git a/app/(main)/admin/email/page.tsx b/app/(main)/admin/email/page.tsx index bc391da..469ddc1 100644 --- a/app/(main)/admin/email/page.tsx +++ b/app/(main)/admin/email/page.tsx @@ -5,6 +5,7 @@ import { getServerSession } from 'next-auth' import { AdminShell } from '@/packages/components/admin/admin-shell' import { authOptions } from '@/packages/lib/auth' +import { isCloudEnabled } from '@/packages/lib/config/env' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' export const metadata = buildPageMetadata({ @@ -13,26 +14,36 @@ export const metadata = buildPageMetadata({ }) const AdminEmailManager = dynamic(() => - import('@/packages/components/admin/email/email-manager').then((m) => m.AdminEmailManager) + import('@/packages/components/admin/email/email-manager').then( + (m) => m.AdminEmailManager + ) ) export default async function AdminEmailPage() { - const session = await getServerSession(authOptions) - - if (!session?.user || (session.user.role !== 'SUPERADMIN')) { - redirect('/dashboard') - } - - return ( - -
-

Email Broadcasts

-

Send announcements and operational emails to users.

-
-
- }> - - - ) + if (!isCloudEnabled()) redirect('/admin') + + const session = await getServerSession(authOptions) + + if (!session?.user || session.user.role !== 'SUPERADMIN') { + redirect('/dashboard') + } + + return ( + +
+

+ Email Broadcasts +

+

+ Send announcements and operational emails to users. +

+
+
+ } + > + + + ) } diff --git a/app/(main)/admin/overview-client.tsx b/app/(main)/admin/overview-client.tsx index 44c7ef6..b2a0435 100644 --- a/app/(main)/admin/overview-client.tsx +++ b/app/(main)/admin/overview-client.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react' import { Badge } from '@/packages/components/ui/badge' +import { SystemHealthPanel } from '@/packages/components/admin/system-health' interface AdminOverviewProps { userCount: number @@ -25,23 +26,86 @@ interface AdminOverviewProps { pendingReports: number pendingApplications: number isSuperAdmin: boolean + cloudEnabled: boolean } const adminSections = [ - { href: '/admin/blog', label: 'Blogs', description: 'Publish and curate blog posts', icon: BookOpen }, - { href: '/admin/legal', label: 'Legal', description: 'Manage policies and legal documents', icon: Gavel }, - { href: '/admin/users', label: 'Users', description: 'Manage user accounts and roles', icon: Users }, - { href: '/admin/reports', label: 'Reports', description: 'Review user and content reports', icon: ShieldAlert }, - { href: '/admin/applications', label: 'Applications', description: 'Review user applications', icon: ClipboardList }, - { href: '/admin/products', label: 'Products', description: 'Manage plan products and pricing', icon: CreditCard }, - { href: '/admin/partners', label: 'Partners', description: 'Manage partner entries', icon: Handshake }, - { href: '/admin/testimonials', label: 'Testimonials', description: 'Manage user testimonials', icon: MessageSquare }, + { + href: '/admin/blog', + label: 'Blogs', + description: 'Publish and curate blog posts', + icon: BookOpen, + cloudOnly: true, + }, + { + href: '/admin/legal', + label: 'Legal', + description: 'Manage policies and legal documents', + icon: Gavel, + cloudOnly: true, + }, + { + href: '/admin/users', + label: 'Users', + description: 'Manage user accounts and roles', + icon: Users, + }, + { + href: '/admin/reports', + label: 'Reports', + description: 'Review user and content reports', + icon: ShieldAlert, + }, + { + href: '/admin/applications', + label: 'Applications', + description: 'Review user applications', + icon: ClipboardList, + cloudOnly: true, + }, + { + href: '/admin/products', + label: 'Products', + description: 'Manage plan products and pricing', + icon: CreditCard, + cloudOnly: true, + }, + { + href: '/admin/partners', + label: 'Partners', + description: 'Manage partner entries', + icon: Handshake, + cloudOnly: true, + }, + { + href: '/admin/testimonials', + label: 'Testimonials', + description: 'Manage user testimonials', + icon: MessageSquare, + cloudOnly: true, + }, ] const superAdminSections = [ - { href: '/admin/email', label: 'Email', description: 'Send broadcasts and announcements', icon: Mail }, - { href: '/admin/logs', label: 'Audit Logs', description: 'Review system events and actions', icon: ChartBar }, - { href: '/admin/settings', label: 'Settings', description: 'Configure platform-wide settings', icon: Settings }, + { + href: '/admin/email', + label: 'Email', + description: 'Send broadcasts and announcements', + icon: Mail, + cloudOnly: true, + }, + { + href: '/admin/logs', + label: 'Audit Logs', + description: 'Review system events and actions', + icon: ChartBar, + }, + { + href: '/admin/settings', + label: 'Settings', + description: 'Configure platform-wide settings', + icon: Settings, + }, ] export function AdminOverviewContent({ @@ -50,10 +114,11 @@ export function AdminOverviewContent({ pendingReports, pendingApplications, isSuperAdmin, + cloudEnabled, }: AdminOverviewProps) { - const sections = isSuperAdmin - ? [...adminSections, ...superAdminSections] - : adminSections + const sections = ( + isSuperAdmin ? [...adminSections, ...superAdminSections] : adminSections + ).filter((s) => !s.cloudOnly || cloudEnabled) return (
@@ -86,17 +151,23 @@ export function AdminOverviewContent({

Pending reports

-
-
- + {cloudEnabled && ( +
+
+ +
+
+

{pendingApplications}

+

+ Pending applications +

+
-
-

{pendingApplications}

-

Pending applications

-
-
+ )}
+ + {/* Quick Navigation */}

Manage

@@ -124,11 +195,12 @@ export function AdminOverviewContent({ {pendingReports} pending )} - {section.href === '/admin/applications' && pendingApplications > 0 && ( - - {pendingApplications} pending - - )} + {section.href === '/admin/applications' && + pendingApplications > 0 && ( + + {pendingApplications} pending + + )} ))}
diff --git a/app/(main)/admin/page.tsx b/app/(main)/admin/page.tsx index 691524d..16494ca 100644 --- a/app/(main)/admin/page.tsx +++ b/app/(main)/admin/page.tsx @@ -1,6 +1,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/packages/lib/auth' +import { isCloudEnabled } from '@/packages/lib/config/env' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' import { prisma } from '@/packages/lib/database/prisma' @@ -25,24 +26,27 @@ export default async function AdminPage() { ]) return ( - -
-

- Admin Overview -

-

- Platform administration and management. -

+ +
+

+ Admin Overview +

+

+ Platform administration and management. +

+
- - }> + } + >
) diff --git a/app/(main)/admin/products/page.tsx b/app/(main)/admin/products/page.tsx index dd01764..a686a1e 100644 --- a/app/(main)/admin/products/page.tsx +++ b/app/(main)/admin/products/page.tsx @@ -1,5 +1,8 @@ +import { redirect } from 'next/navigation' + import AdminProductManager from '@/packages/components/admin/products/ProductManager' import { AdminShell } from '@/packages/components/admin/admin-shell' +import { isCloudEnabled } from '@/packages/lib/config/env' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' export const metadata = buildPageMetadata({ @@ -8,17 +11,22 @@ export const metadata = buildPageMetadata({ }) export default async function AdminProductsPage() { + if (!isCloudEnabled()) redirect('/admin') + return ( - -
-

Products

-

Manage plan products, pricing, and promo codes.

+ +
+

Products

+

+ Manage plan products, pricing, and promo codes. +

+
- - }> + } + >
) } - diff --git a/app/(main)/admin/reports/page.tsx b/app/(main)/admin/reports/page.tsx index 48c531f..ccfe0d0 100644 --- a/app/(main)/admin/reports/page.tsx +++ b/app/(main)/admin/reports/page.tsx @@ -10,6 +10,7 @@ import { TableHeader, TableRow, } from '@/packages/components/ui/table' +import { isCloudEnabled } from '@/packages/lib/config/env' import { prisma } from '@/packages/lib/database/prisma' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' import { ReportStatus } from '@/prisma/generated/prisma/client' @@ -26,7 +27,12 @@ const STATUS_STYLES: Record = { DISMISSED: 'bg-gray-500/20 text-gray-400 border-gray-500/30', } -const VALID_STATUSES = ['PENDING', 'REVIEWING', 'RESOLVED', 'DISMISSED'] as const +const VALID_STATUSES = [ + 'PENDING', + 'REVIEWING', + 'RESOLVED', + 'DISMISSED', +] as const export default async function AdminReportsPage({ searchParams, @@ -34,12 +40,17 @@ export default async function AdminReportsPage({ searchParams: Promise<{ status?: string; type?: string }> }) { const { status, type } = await searchParams - const filterStatus = - VALID_STATUSES.includes(status as ReportStatus) - ? (status as ReportStatus) - : undefined + const filterStatus = VALID_STATUSES.includes(status as ReportStatus) + ? (status as ReportStatus) + : undefined - const viewType = type === 'squad' ? 'squad' : type === 'content' ? 'content' : 'user' + const cloudEnabled = isCloudEnabled() + const viewType = + type === 'squad' && cloudEnabled + ? 'squad' + : type === 'content' + ? 'content' + : 'user' const [userReports, squadReports, contentReports] = await Promise.all([ viewType === 'user' @@ -49,7 +60,13 @@ export default async function AdminReportsPage({ take: 50, include: { reportedUser: { - select: { id: true, name: true, email: true, urlId: true, image: true }, + select: { + id: true, + name: true, + email: true, + urlId: true, + image: true, + }, }, reporterUser: { select: { id: true, name: true, email: true, urlId: true }, @@ -85,7 +102,12 @@ export default async function AdminReportsPage({ select: { id: true, name: true, urlPath: true, flagged: true }, }, url: { - select: { id: true, shortCode: true, targetUrl: true, flagged: true }, + select: { + id: true, + shortCode: true, + targetUrl: true, + flagged: true, + }, }, }, }) @@ -93,29 +115,32 @@ export default async function AdminReportsPage({ ]) return ( - -
-

Reports

-

- Review and resolve user and squad reports -

+ +
+

Reports

+

+ Review and resolve user and squad reports +

+
- - }> + } + >
- {/* Type tabs */} -
- - User Reports - + {/* Type tabs */} +
+ + User Reports + + {cloudEnabled && ( Squad Reports - - Content Reports - -
+ )} + + Content Reports + +
- {/* Status filters */} -
- {([undefined, ...VALID_STATUSES] as const).map((s) => { - const typeParam = viewType !== 'user' ? `type=${viewType}` : '' - const statusParam = s ? `status=${s}` : '' - const query = [typeParam, statusParam].filter(Boolean).join('&') - return ( - - {s ?? 'All'} - - ) - })} -
+ {/* Status filters */} +
+ {([undefined, ...VALID_STATUSES] as const).map((s) => { + const typeParam = viewType !== 'user' ? `type=${viewType}` : '' + const statusParam = s ? `status=${s}` : '' + const query = [typeParam, statusParam].filter(Boolean).join('&') + return ( + + {s ?? 'All'} + + ) + })} +
- {viewType === 'user' ? ( - <> -

- Showing {userReports.length} user report{userReports.length !== 1 ? 's' : ''} - {filterStatus ? ` with status "${filterStatus}"` : ''} -

+ {viewType === 'user' ? ( + <> +

+ Showing {userReports.length} user report + {userReports.length !== 1 ? 's' : ''} + {filterStatus ? ` with status "${filterStatus}"` : ''} +

- - - - ID - Reporter - Reported User - Category - Status - Date - Actions - - - - {userReports.length === 0 ? ( +
+ - - No user reports found - + ID + Reporter + Reported User + Category + Status + Date + Actions - ) : ( - userReports.map((report) => ( - - - {report.id.slice(0, 8)}… - - -
- {report.reporterUser.name ?? '—'} -
-
- {report.reporterUser.email} -
-
- -
- {report.reportedUser.name ?? '—'} -
-
- {report.reportedUser.email} -
-
- - - {report.category.toLowerCase().replace(/_/g, ' ')} - - - - - {report.status} - - - - {report.createdAt.toLocaleDateString()} +
+ + {userReports.length === 0 ? ( + + + No user reports found - -
- + ) : ( + userReports.map((report) => ( + + + {report.id.slice(0, 8)}… + + +
+ {report.reporterUser.name ?? '—'} +
+
+ {report.reporterUser.email} +
+
+ +
+ {report.reportedUser.name ?? '—'} +
+
+ {report.reportedUser.email} +
+
+ + + {report.category.toLowerCase().replace(/_/g, ' ')} + + + + - View - - {report.reportedUser.urlId && ( + {report.status} + + + + {report.createdAt.toLocaleDateString()} + + +
- Profile ↗ + View - )} -
-
-
- )) - )} - -
- - ) : viewType === 'squad' ? ( - <> -

- Showing {squadReports.length} squad report{squadReports.length !== 1 ? 's' : ''} - {filterStatus ? ` with status "${filterStatus}"` : ''} -

+ {report.reportedUser.urlId && ( + + Profile ↗ + + )} +
+ + + )) + )} + + + + ) : viewType === 'squad' ? ( + <> +

+ Showing {squadReports.length} squad report + {squadReports.length !== 1 ? 's' : ''} + {filterStatus ? ` with status "${filterStatus}"` : ''} +

- - +
+ + + ID + Reporter + Squad + Category + Status + Date + + + + {squadReports.length === 0 ? ( - ID - Reporter - Squad - Category - Status - Date + + No squad reports found + - - - {squadReports.length === 0 ? ( - - - No squad reports found + ) : ( + squadReports.map((report) => ( + + + {report.id.slice(0, 8)}… + + +
+ {report.reporterUser.name ?? '—'} +
+
+ {report.reporterUser.email} +
+
+ +
+ {report.squad.name} +
+
+ /{report.squad.slug} +
+
+ + + {report.category.toLowerCase().replace(/_/g, ' ')} + + + + + {report.status} + + + + {report.createdAt.toLocaleDateString()}
- ) : ( - squadReports.map((report) => ( + )) + )} +
+
+ + ) : ( + <> +

+ Showing {contentReports.length} content report + {contentReports.length !== 1 ? 's' : ''} + {filterStatus ? ` with status "${filterStatus}"` : ''} +

+ + + + + ID + Reporter + Type + Content + Category + Status + Flagged + Date + + + + {contentReports.length === 0 ? ( + + + No content reports found + + + ) : ( + contentReports.map((report) => { + const contentName = + report.file?.name ?? report.url?.shortCode ?? '—' + const contentPath = + report.file?.urlPath ?? + (report.url ? `/u/${report.url.shortCode}` : null) + const isFlagged = + report.file?.flagged ?? report.url?.flagged ?? false + return ( {report.id.slice(0, 8)}… @@ -296,12 +407,23 @@ export default async function AdminReportsPage({ -
- {report.squad.name} -
-
- /{report.squad.slug} -
+ + {report.contentType} + +
+ + {contentPath ? ( + + {contentName} + + ) : ( + {contentName} + )} @@ -316,116 +438,32 @@ export default async function AdminReportsPage({ {report.status} - - {report.createdAt.toLocaleDateString()} - -
- )) - )} -
-
- - ) : ( - <> -

- Showing {contentReports.length} content report{contentReports.length !== 1 ? 's' : ''} - {filterStatus ? ` with status "${filterStatus}"` : ''} -

- - - - - ID - Reporter - Type - Content - Category - Status - Flagged - Date - - - - {contentReports.length === 0 ? ( - - - No content reports found - - - ) : ( - contentReports.map((report) => { - const contentName = report.file?.name ?? report.url?.shortCode ?? '—' - const contentPath = report.file?.urlPath ?? (report.url ? `/u/${report.url.shortCode}` : null) - const isFlagged = report.file?.flagged ?? report.url?.flagged ?? false - return ( - - - {report.id.slice(0, 8)}… - - -
- {report.reporterUser.name ?? '—'} -
-
- {report.reporterUser.email} -
-
- - - {report.contentType} - - - - {contentPath ? ( - - {contentName} - - ) : ( - {contentName} - )} - - - - {report.category.toLowerCase().replace(/_/g, ' ')} - - - + + {isFlagged ? ( - {report.status} + Flagged - - - {isFlagged ? ( - - Flagged - - ) : ( - No - )} - - - {report.createdAt.toLocaleDateString()} - -
- ) - }) - )} -
-
- - )} - + ) : ( + + No + + )} + + + {report.createdAt.toLocaleDateString()} + + + ) + }) + )} + + + + )} +
) } diff --git a/app/(main)/applications/[type]/page.tsx b/app/(main)/applications/[type]/page.tsx deleted file mode 100644 index ca03599..0000000 --- a/app/(main)/applications/[type]/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { notFound, redirect } from 'next/navigation' - -import { getServerSession } from 'next-auth' - -import { authOptions } from '@/packages/lib/auth' -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' -import { ApplicationForm } from '@/packages/components/applications/application-form' -import HomeShell from '@/packages/components/layout/home-shell' - -type ApplicationType = 'staff' | 'partner' | 'verification' | 'ban-appeal' - -const VALID_TYPES: ApplicationType[] = ['staff', 'partner', 'verification', 'ban-appeal'] - -const TYPE_META: Record = { - staff: { - title: 'Staff Application', - description: 'Apply to join the Emberly team.', - }, - partner: { - title: 'Partner Application', - description: 'Apply to become an Emberly partner.', - }, - verification: { - title: 'Verification Request', - description: 'Request profile verification on Emberly.', - }, - 'ban-appeal': { - title: 'Ban Appeal', - description: 'Submit an appeal to have your account suspension reviewed.', - }, -} - -interface PageProps { - params: Promise<{ type: string }> -} - -export async function generateMetadata({ params }: PageProps) { - const { type } = await params - if (!VALID_TYPES.includes(type as ApplicationType)) return {} - const meta = TYPE_META[type as ApplicationType] - return buildPageMetadata({ title: meta.title, description: meta.description }) -} - -export default async function ApplicationTypePage({ params }: PageProps) { - const { type } = await params - - if (!VALID_TYPES.includes(type as ApplicationType)) { - notFound() - } - - const session = await getServerSession(authOptions) - if (!session?.user) { - redirect('/auth/login') - } - - const applicationType = type as ApplicationType - const meta = TYPE_META[applicationType] - - return ( - -
-
-

{meta.title}

-

{meta.description}

-
- -
-
- ) -} diff --git a/app/(main)/applications/page.tsx b/app/(main)/applications/page.tsx deleted file mode 100644 index 4071a95..0000000 --- a/app/(main)/applications/page.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import Link from 'next/link' - -import { getServerSession } from 'next-auth' -import { - ArrowRight, - BadgeCheck, - Briefcase, - Handshake, - LogIn, - Scale, -} from 'lucide-react' - -import { authOptions } from '@/packages/lib/auth' -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' -import { Button } from '@/packages/components/ui/button' -import { Badge } from '@/packages/components/ui/badge' -import HomeShell from '@/packages/components/layout/home-shell' - -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -export const metadata = buildPageMetadata({ - title: 'Applications', - description: 'Apply to join the Emberly team, become a partner, request verification, or appeal a ban.', -}) - -const APPLICATION_CARDS = [ - { - type: 'staff', - icon: Briefcase, - title: 'Join Our Team', - description: 'We\'re looking for passionate people who want to make a difference.', - color: 'text-chart-1', - bg: 'bg-chart-1/10', - badge: 'Staff Application', - }, - { - type: 'partner', - icon: Handshake, - title: 'Partner With Us', - description: 'Collaborate and cross-promote. Grow your audience alongside Emberly.', - color: 'text-chart-3', - bg: 'bg-chart-3/10', - badge: 'Partnership', - }, - { - type: 'verification', - icon: BadgeCheck, - title: 'Get Verified', - description: 'Request verification for your profile to build trust with your audience.', - color: 'text-primary', - bg: 'bg-primary/10', - badge: 'Verification', - }, - { - type: 'ban-appeal', - icon: Scale, - title: 'Appeal a Ban', - description: 'Request review of an account suspension by a senior team member.', - color: 'text-chart-5', - bg: 'bg-chart-5/10', - badge: 'Ban Appeal', - }, -] - -export default async function ApplicationsPage() { - const session = await getServerSession(authOptions) - - return ( - -
- {/* Hero */} - -
-
- - - Applications - -

- Get involved with - - Emberly. - -

-

- Whether you want to join our team, partner with us, get verified, or appeal a decision submit your application below. -

-
-
-
- - {!session?.user ? ( - /* Not logged in */ - -
-
- -
-
-

Sign in to apply

-

- You must be logged in to submit an application. -

-
- -
-
- ) : ( - /* Logged in — show cards */ -
- {APPLICATION_CARDS.map((card) => ( - -
-
-
- -
-
-
-

{card.title}

- - {card.badge} - -
-

- {card.description} -

-
-
-
- -
-
-
- ))} -
- )} -
-
- ) -} diff --git a/app/(main)/changelogs/page.tsx b/app/(main)/changelogs/page.tsx deleted file mode 100644 index f1d22c4..0000000 --- a/app/(main)/changelogs/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' -import ChangelogList from '@/packages/components/changelogs/ChangelogList' -import { DashboardWrapper } from '@/packages/components/dashboard/dashboard-wrapper' -import { getConfig } from '@/packages/lib/config' -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' - -export const metadata = buildPageMetadata({ - title: 'Changelogs', - description: 'View releases across all of the Emberly services. Powered by GitHub Releases!', -}) - -export default async function Page() { - const config = await getConfig() - const { value, unit } = config.settings.general.storage.maxUploadSize - const maxSizeBytes = value * (unit === 'GB' ? 1024 * 1024 * 1024 : 1024 * 1024) - - return ( - -
-
-
-
-
-

Changelogs

-

View releases across all of the Emberly services. Powered by GitHub Releases!

-
-
-
-
- - -
-
- ) -} diff --git a/app/(main)/contact/page.tsx b/app/(main)/contact/page.tsx deleted file mode 100644 index e35228c..0000000 --- a/app/(main)/contact/page.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import Link from 'next/link' - -import { - ArrowRight, - BookOpen, - Bug, - Clock, - Github, - Globe, - Headphones, - Mail, - MessageCircle, - MessagesSquare, - Newspaper, - Shield, - Twitter, - Users, - Zap, -} from 'lucide-react' - -import { Button } from '@/packages/components/ui/button' -import { Badge } from '@/packages/components/ui/badge' -import HomeShell from '@/packages/components/layout/home-shell' - -// Reusable GlassCard component -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -const CONTACT_CHANNELS = [ - { - icon: Github, - title: 'GitHub Issues', - description: 'Report bugs, request features, or contribute to the codebase.', - href: 'https://github.com/EmberlyOSS/Emberly/issues', - label: 'Open an issue', - external: true, - color: 'text-foreground', - bg: 'bg-foreground/10', - }, - { - icon: MessagesSquare, - title: 'GitHub Discussions', - description: 'Community help, ideas, and design discussion.', - href: 'https://github.com/orgs/EmberlyOSS/discussions', - label: 'Join discussions', - external: true, - color: 'text-chart-3', - bg: 'bg-chart-3/10', - }, - { - icon: Users, - title: 'Discord Community', - description: 'Real-time chat, support, and community hangouts.', - href: '/discord', - label: 'Join Discord', - external: false, - color: 'text-chart-1', - bg: 'bg-chart-1/10', - }, - { - icon: Twitter, - title: 'Twitter / X', - description: 'Follow for announcements and quick updates.', - href: 'https://twitter.com/TryEmberly', - label: 'Follow us', - external: true, - color: 'text-chart-5', - bg: 'bg-chart-5/10', - }, -] - -const EMAIL_CONTACTS = [ - { - icon: Globe, - label: 'General Inquiries', - email: 'hello@embrly.ca', - description: 'Questions about Emberly or just want to say hi', - }, - { - icon: Headphones, - label: 'Support', - email: 'support@embrly.ca', - description: 'Technical help and account issues', - }, - { - icon: Newspaper, - label: 'Press & Partnerships', - email: 'press@embrly.ca', - description: 'Media inquiries and collaboration opportunities', - }, - { - icon: Shield, - label: 'Security', - email: 'security@embrly.ca', - description: 'Report vulnerabilities responsibly', - }, -] - -const QUICK_LINKS = [ - { icon: BookOpen, label: 'Blog', href: '/blog' }, - { icon: Bug, label: 'Bug Reports', href: 'https://github.com/EmberlyOSS/Emberly/issues/new?template=bug_report.md' }, - { icon: Zap, label: 'Feature Requests', href: 'https://github.com/EmberlyOSS/Emberly/issues/new?template=feature_request.md' }, -] - -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' - -export const metadata = buildPageMetadata({ - title: 'Contact', - description: 'Get in touch with the Emberly team - we\'d love to hear from you.', -}) - -export default function ContactPage() { - return ( - -
- {/* Hero Section */} - -
-
- - - Get in Touch - -

- We'd love to - - hear from you. - -

-

- Questions, feedback, partnership inquiries, or just want to say hi? - Choose your preferred way to reach us below. -

-
-
-
- - {/* Main Content Grid */} -
- {/* Contact Channels */} -
-

Community & Support

-
- {CONTACT_CHANNELS.map((channel) => ( - -
-
-
- -
-
-

{channel.title}

-

- {channel.description} -

-
-
-
- -
-
-
- ))} -
-
- - {/* Email Contacts */} -
-

Email Us

- -
- {EMAIL_CONTACTS.map((contact, index) => ( -
-
-
- -
-
-
{contact.label}
- - {contact.email} - -

- {contact.description} -

-
-
- {index < EMAIL_CONTACTS.length - 1 && ( -
- )} -
- ))} -
- - - {/* Response Time */} - -
-
-
- -
-
-
Response Time
-

- Usually within 1-2 business days -

-
-
-
-
-
-
- - {/* Quick Links */} - -
-
-
-

Quick Links

-

- Looking for something specific? -

-
-
- {QUICK_LINKS.map((link) => ( - - ))} -
-
-
-
- - {/* CTA Section */} - -
- -

Can't find what you're looking for?

-

- Check out our blog for the latest updates and guides, - or reach out directly and we'll point you in the right direction. -

-
- - -
-
-
-
- - ) -} diff --git a/app/(main)/dashboard/bucket/page.tsx b/app/(main)/dashboard/bucket/page.tsx index efba8c6..8d5ad6c 100644 --- a/app/(main)/dashboard/bucket/page.tsx +++ b/app/(main)/dashboard/bucket/page.tsx @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth' import { AlertTriangle, Clock, Key, Server } from 'lucide-react' import { authOptions } from '@/packages/lib/auth' +import { isCloudEnabled } from '@/packages/lib/config/env' import { prisma } from '@/packages/lib/database/prisma' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' import { provisionBucketForUserSubscription } from '@/packages/lib/storage/bucket-provisioning' @@ -15,6 +16,8 @@ export const metadata = buildPageMetadata({ }) export default async function BucketPage() { + if (!isCloudEnabled()) redirect('/dashboard') + const session = await getServerSession(authOptions) if (!session?.user?.id) redirect('/auth/signin') diff --git a/app/(main)/dashboard/client.tsx b/app/(main)/dashboard/client.tsx index 6ed4701..b486c61 100644 --- a/app/(main)/dashboard/client.tsx +++ b/app/(main)/dashboard/client.tsx @@ -1,6 +1,7 @@ 'use client' import Link from 'next/link' +import { isCloudEnabledClient } from '@/packages/lib/config/env' import { FolderOpen, Upload, @@ -53,6 +54,7 @@ const quickActions = [ label: 'Domains', description: 'Connect your own domains to Emberly', icon: Globe, + cloudOnly: true, }, { href: '/dashboard/analytics', @@ -65,12 +67,14 @@ const quickActions = [ label: 'Discovery', description: 'Manage your Nexium talent profile', icon: Sparkles, + cloudOnly: true, }, { href: '/dashboard/bucket', label: 'Buckets', description: 'Access your S3-compatible storage', icon: Database, + cloudOnly: true, }, ] @@ -80,6 +84,11 @@ export function DashboardIndex({ urlCount, storageUsed, }: DashboardIndexProps) { + const cloudEnabled = isCloudEnabledClient() + const visibleActions = quickActions.filter( + (a) => !a.cloudOnly || cloudEnabled + ) + return (
{/* Stats */} @@ -117,7 +126,7 @@ export function DashboardIndex({

Quick Actions

- {quickActions.map((action) => ( + {visibleActions.map((action) => ( -
-
-
-

Discovery

-

- Manage your talent profile, squads, signals, and team resources -

-
- {user?.isProfilePublic && ( + +
+
+
+

Discovery

+

+ Manage your talent profile, squads, signals, and team + resources +

+
+ {user?.isProfilePublic && ( + + View Public Profile ↗ + + )} + + Profile Settings ↗ + - View Public Profile ↗ + Applications ↗ - )} - - Profile Settings ↗ - - - Applications ↗ - +
+
-
-
- }> + } + > ) diff --git a/app/(main)/dashboard/domains/page.tsx b/app/(main)/dashboard/domains/page.tsx index c3f5e84..6776b66 100644 --- a/app/(main)/dashboard/domains/page.tsx +++ b/app/(main)/dashboard/domains/page.tsx @@ -1,4 +1,7 @@ +import { redirect } from 'next/navigation' + import { buildPageMetadata } from '@/packages/lib/embeds/metadata' +import { isCloudEnabled } from '@/packages/lib/config/env' import { ProfileDomains } from '@/packages/components/dashboard/domains' import { DashboardShell } from '@/packages/components/dashboard/dashboard-shell' @@ -8,13 +11,16 @@ export const metadata = buildPageMetadata({ }) export default async function DomainsPage() { + if (!isCloudEnabled()) redirect('/dashboard') return (
-

Custom Domains

+

+ Custom Domains +

Connect your own domains to serve files from branded URLs.

diff --git a/app/(main)/dashboard/squads/[id]/page.tsx b/app/(main)/dashboard/squads/[id]/page.tsx index 7973e8e..89d1219 100644 --- a/app/(main)/dashboard/squads/[id]/page.tsx +++ b/app/(main)/dashboard/squads/[id]/page.tsx @@ -3,12 +3,17 @@ import { redirect } from 'next/navigation' import { getServerSession } from 'next-auth' import { authOptions } from '@/packages/lib/auth' +import { isCloudEnabled } from '@/packages/lib/config/env' import { prisma } from '@/packages/lib/database/prisma' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' import { SquadDashboardClient } from './client' -export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }> +}) { const { id } = await params const squad = await prisma.nexiumSquad.findUnique({ where: { id }, @@ -20,7 +25,13 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin }) } -export default async function SquadDashboardPage({ params }: { params: Promise<{ id: string }> }) { +export default async function SquadDashboardPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + if (!isCloudEnabled()) redirect('/dashboard') + const session = await getServerSession(authOptions) if (!session?.user) redirect('/auth/login') diff --git a/app/(main)/dashboard/squads/page.tsx b/app/(main)/dashboard/squads/page.tsx index 38b705e..4c45c5e 100644 --- a/app/(main)/dashboard/squads/page.tsx +++ b/app/(main)/dashboard/squads/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation' import { getServerSession } from 'next-auth' import { authOptions } from '@/packages/lib/auth' +import { isCloudEnabled } from '@/packages/lib/config/env' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' import { DashboardShell } from '@/packages/components/dashboard/dashboard-shell' @@ -11,10 +12,13 @@ import { SquadsClient } from './client' export const metadata = buildPageMetadata({ title: 'Squads', - description: 'Create and manage teams with shared uploads, domains, and resources.', + description: + 'Create and manage teams with shared uploads, domains, and resources.', }) export default async function SquadsPage() { + if (!isCloudEnabled()) redirect('/dashboard') + const session = await getServerSession(authOptions) if (!session?.user) redirect('/auth/login') @@ -25,7 +29,8 @@ export default async function SquadsPage() {

Squads

- Create and manage teams with shared uploads, domains, and resources + Create and manage teams with shared uploads, domains, and + resources

diff --git a/app/(main)/error.tsx b/app/(main)/error.tsx index 7f35648..ed7d071 100644 --- a/app/(main)/error.tsx +++ b/app/(main)/error.tsx @@ -16,17 +16,22 @@ import { RefreshCcw, } from 'lucide-react' +import { DISCORD_INVITE_URL } from '@/packages/lib/constants/site' import { DynamicBackground } from '@/packages/components/layout/dynamic-background' import { DashboardNav } from '@/packages/components/dashboard/nav' import { Button } from '@/packages/components/ui/button' import { Badge } from '@/packages/components/ui/badge' // Reusable GlassCard component -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { return ( -
- {children} -
+
{children}
) } @@ -54,7 +59,7 @@ export default function Error({ await navigator.clipboard.writeText(errorInfo) setCopied(true) setTimeout(() => setCopied(false), 2000) - } catch { } + } catch {} } return ( @@ -92,7 +97,10 @@ export default function Error({ Error {error?.digest && ( - + Code: {error.digest} )} @@ -117,7 +125,12 @@ export default function Error({ Try Again -
- -
@@ -151,54 +167,56 @@ export default async function ProfilePage() {
+ user={{ + id: user.id, + name: user.name, + fullName: user.fullName, + email: user.email, + image: user.image, + storageUsed: user.storageUsed, + role: user.role, + randomizeFileUrls: user.randomizeFileUrls, + enableRichEmbeds: user.enableRichEmbeds ?? true, + emailNotificationsEnabled: user.emailNotificationsEnabled ?? true, + emailPreferences: (user.emailPreferences as any) ?? undefined, + discordWebhookUrl: user.discordWebhookUrl ?? null, + discordNotificationsEnabled: + user.discordNotificationsEnabled ?? false, + discordPreferences: (user.discordPreferences as any) ?? undefined, + urlId: user.urlId, + vanityId: user.vanityId, + bio: user.bio, + website: user.website, + twitter: user.twitter, + github: user.github, + discord: user.discord, + isProfilePublic: user.isProfilePublic, + showLinkedAccounts: user.showLinkedAccounts, + stripeCustomerId: user.stripeCustomerId ?? null, + subscription: subscriptions?.[0] + ? { + id: subscriptions[0].id, + productId: subscriptions[0].productId, + productName: (subscriptions[0] as any).product?.name ?? null, + status: subscriptions[0].status, + currentPeriodEnd: subscriptions[0].currentPeriodEnd + ? subscriptions[0].currentPeriodEnd.toISOString() + : null, + } + : null, + fileCount: user._count.files, + shortUrlCount: user._count.shortenedUrls, + defaultFileExpiration: user.defaultFileExpiration, + defaultFileExpirationAction: user.defaultFileExpirationAction, + passwordBreachDetectedAt: + user.passwordBreachDetectedAt?.toISOString() || null, + }} + quotasEnabled={quotasEnabled} + formattedQuota={formattedQuota} + formattedUsed={formattedUsed} + usagePercentage={usagePercentage} + isAdmin={user.role === 'ADMIN'} + />
) } diff --git a/app/(main)/not-found.tsx b/app/(main)/not-found.tsx index f8a5c0d..a729617 100644 --- a/app/(main)/not-found.tsx +++ b/app/(main)/not-found.tsx @@ -1,16 +1,16 @@ import Link from 'next/link' import { - ArrowRight, - BookOpen, - FileQuestion, - Home, - LayoutDashboard, - MessageCircle, - Scale, - Search, - Settings, - Sparkles, + ArrowRight, + BookOpen, + FileQuestion, + Home, + LayoutDashboard, + MessageCircle, + Scale, + Search, + Settings, + Sparkles, } from 'lucide-react' import MiniGame from '@/packages/components/games/mini-game' @@ -18,165 +18,198 @@ import { Button } from '@/packages/components/ui/button' import { Badge } from '@/packages/components/ui/badge' import { DynamicBackground } from '@/packages/components/layout/dynamic-background' import { DashboardNav } from '@/packages/components/dashboard/nav' +import { isCloudEnabled } from '@/packages/lib/config/env' +import { DISCORD_INVITE_URL } from '@/packages/lib/constants/site' // Reusable GlassCard component -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
{children}
+ ) } const SUGGESTED_PAGES = [ - { - icon: LayoutDashboard, - title: 'Dashboard', - description: 'Return to your files', - href: '/dashboard', - color: 'text-primary', - bg: 'bg-primary/10', - }, - { - icon: Settings, - title: 'Settings', - description: 'Manage your account', - href: '/dashboard/settings', - color: 'text-blue-500', - bg: 'bg-blue-500/10', - }, - { - icon: MessageCircle, - title: 'Discord', - description: 'Get help from the community', - href: '/discord', - color: 'text-indigo-500', - bg: 'bg-indigo-500/10', - }, - { - icon: BookOpen, - title: 'Blog', - description: 'Read the latest updates', - href: '/blog', - color: 'text-emerald-500', - bg: 'bg-emerald-500/10', - }, + { + icon: LayoutDashboard, + title: 'Dashboard', + description: 'Return to your files', + href: '/dashboard', + color: 'text-primary', + bg: 'bg-primary/10', + }, + { + icon: Settings, + title: 'Settings', + description: 'Manage your account', + href: '/dashboard/settings', + color: 'text-blue-500', + bg: 'bg-blue-500/10', + }, + { + icon: MessageCircle, + title: 'Discord', + description: 'Get help from the community', + href: DISCORD_INVITE_URL, + external: true, + color: 'text-indigo-500', + bg: 'bg-indigo-500/10', + }, + { + icon: BookOpen, + title: 'Blog', + description: 'Read the latest updates', + href: '/blog', + color: 'text-emerald-500', + bg: 'bg-emerald-500/10', + cloudOnly: true, + }, ] export default function NotFound() { - return ( -
- + const cloudEnabled = isCloudEnabled() + const suggestedPages = SUGGESTED_PAGES.filter( + (page) => !page.cloudOnly || cloudEnabled + ) -
-
-
-
- -
-
-
-
+ return ( +
+ -
-
-
- {/* Main Content */} - -
- {/* 404 Badge */} - - - 404 - +
+
+
+
+ +
+
+
+
- {/* Title */} -

- Page not - - found - -

+
+
+
+ {/* Main Content */} + +
+ {/* 404 Badge */} + + + 404 + - {/* Description */} -

- The page you're looking for doesn't exist or has been moved. - Don't worry - let's get you back on track. -

+ {/* Title */} +

+ Page not + + found + +

- {/* Actions */} -
- - -
+ {/* Description */} +

+ The page you're looking for doesn't exist or has been moved. + Don't worry - let's get you back on track. +

- {/* Fun Message */} -
-
- -
-

While you're here...

-

- Why not play a quick game? Click "Play a Game" and try to beat your high score! -

-
-
-
-
-
+ {/* Actions */} +
+ + +
- {/* Suggested Pages */} -
-
- -

Looking for something?

-
+ {/* Fun Message */} +
+
+ +
+

+ While you're here... +

+

+ Why not play a quick game? Click "Play a Game" and try + to beat your high score! +

+
+
+
+
+ -
- {SUGGESTED_PAGES.map((page) => ( - - -
-
- -
-
-
- {page.title} -
-
- {page.description} -
-
- -
- -
- ))} -
+ {/* Suggested Pages */} +
+
+ +

+ Looking for something? +

+
- {/* Additional Help */} - -
-

- Still can't find what you need?{' '} - - Contact us - - {' '}and we'll help you out. -

-
-
+
+ {suggestedPages.map((page) => ( + + +
+
+
-
+
+
+ {page.title} +
+
+ {page.description} +
+
+ +
+ + + ))} +
+ + {/* Additional Help */} + +
+

+ Still can't find what you need?{' '} + + Contact us + {' '} + and we'll help you out. +

-
+ +
+
- ) +
+
+ ) } diff --git a/app/(main)/press/media-kit/layout.tsx b/app/(main)/press/media-kit/layout.tsx deleted file mode 100644 index b2bb822..0000000 --- a/app/(main)/press/media-kit/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' - -export const metadata = buildPageMetadata({ - title: 'Media Kit', - description: 'Brand assets, logos, colors, and guidelines for press and partners.', -}) - -export default function MediaKitLayout({ children }: { children: React.ReactNode }) { - return children -} diff --git a/app/(main)/press/media-kit/page.tsx b/app/(main)/press/media-kit/page.tsx deleted file mode 100644 index 240c73f..0000000 --- a/app/(main)/press/media-kit/page.tsx +++ /dev/null @@ -1,594 +0,0 @@ -'use client' - -import { useEffect, useState, useRef } from 'react' -import Image from 'next/image' -import Link from 'next/link' - -import { - ArrowLeft, - BookOpen, - Check, - Copy, - Download, - ExternalLink, - Github, - Mail, - Palette, - Play, - Shield, - Sparkles, - Type, - Video, - X, -} from 'lucide-react' - -import { Button } from '@/packages/components/ui/button' -import { Badge } from '@/packages/components/ui/badge' -import HomeShell from '@/packages/components/layout/home-shell' - -// Reusable GlassCard component -function GlassCard({ children, className = '', id }: { children: React.ReactNode; className?: string; id?: string }) { - return ( -
- {children} -
- ) -} - -const BRAND_ASSETS = [ - { - title: 'Primary Logo', - description: 'Full-color logo for light and dark backgrounds.', - preview: 'primary', - theme: 'dark', - download: '/icon.svg', - }, - { - title: 'Icon Mark', - description: 'Minimal ember icon for favicons and avatars.', - preview: 'icon', - theme: 'light', - download: '/icon.svg', - }, - { - title: 'Wordmark', - description: 'Text-only logo for inline use and headers.', - preview: 'wordmark', - theme: 'dark', - download: '/icon.svg', - }, - { - title: 'Monochrome', - description: 'Single-color version for limited color contexts.', - preview: 'mono', - theme: 'light', - download: '/icon.svg', - }, -] - -// Theme-aware colors that pull from CSS variables -const THEME_COLORS = [ - { name: 'Primary', cssVar: '--primary', usage: 'Brand color, CTAs, links' }, - { name: 'Accent', cssVar: '--accent', usage: 'Highlights, badges' }, - { name: 'Background', cssVar: '--background', usage: 'Page backgrounds' }, - { name: 'Card', cssVar: '--card', usage: 'Card surfaces' }, - { name: 'Muted', cssVar: '--muted', usage: 'Subtle backgrounds' }, - { name: 'Foreground', cssVar: '--foreground', usage: 'Primary text' }, -] - -const TYPOGRAPHY = [ - { name: 'Headings', family: 'Inter', weight: 'Bold (700-800)', usage: 'Page titles, section headers' }, - { name: 'Body', family: 'Inter', weight: 'Regular (400)', usage: 'Paragraphs, descriptions' }, - { name: 'UI Elements', family: 'Inter', weight: 'Medium (500)', usage: 'Buttons, labels, navigation' }, -] - -const USAGE_DOS = [ - 'Use official logos from this kit', - 'Maintain clear space around the logo', - 'Use on appropriate contrast backgrounds', - 'Scale proportionally', -] - -const USAGE_DONTS = [ - 'Stretch or distort the logo', - 'Change brand colors', - 'Add effects like shadows or gradients', - 'Use on busy or clashing backgrounds', -] - -const PROMO_VIDEOS = [ - { - title: 'Site Preview', - description: 'Quick overview of the Emberly interface and main features.', - src: '/videos/site-preview-ad.mp4', - duration: '~15s', - }, - { - title: 'Upload Flow', - description: 'Demonstration of the seamless file upload experience.', - src: '/videos/uploading-ad.mp4', - duration: '~10s', - }, -] - -const CONTACT_POINTS = [ - { - label: 'Press inquiries', - href: 'mailto:press@embrly.ca', - icon: Mail, - }, - { - label: 'GitHub Issues', - href: 'https://github.com/EmberlyOSS/Emberly/issues', - icon: Github, - }, -] - -const KIT_DOWNLOAD = '/emberly-media-kit.zip' - -export default function MediaKitPage() { - return ( - -
- {/* Header */} -
-
- -

Media Kit

-

- Official brand assets, color palette, and usage guidelines. - Everything you need to represent Emberly correctly. -

-
-
- -
-
- - {/* Logo Assets */} -
-
- -

Logo Assets

-
-
- {BRAND_ASSETS.map((asset) => ( - -
-
-
-

{asset.title}

-

{asset.description}

-
- -
- -
-
- ))} -
-
- - {/* Promotional Videos */} -
-
-
-

- Short promotional clips for use in presentations, social media, and press coverage. -

-
- {PROMO_VIDEOS.map((video) => ( - -
-
-
-

{video.title}

-

{video.description}

-

Duration: {video.duration}

-
- -
- -
-
- ))} -
-
- - {/* Color Palette */} - -
-
- -

Theme Color Palette

-
-

- Colors adapt to the active theme. These are the current theme's colors. -

- -
-
- - {/* Typography */} - -
-
- -

Typography

-
-
- {TYPOGRAPHY.map((type) => ( -
-
- Aa -
-
{type.name}
-
- {type.family} · {type.weight} -
-
{type.usage}
-
- ))} -
-
-

- Note: Inter is available for free from{' '} - - Google Fonts - - . Use system fonts as a fallback. -

-
-
-
- - {/* Usage Guidelines */} -
- -
-
- -

Do's

-
-
    - {USAGE_DOS.map((item) => ( -
  • -
    - -
    - {item} -
  • - ))} -
-
-
- - -
-
- -

Don'ts

-
-
    - {USAGE_DONTS.map((item) => ( -
  • -
    - -
    - {item} -
  • - ))} -
-
-
-
- - {/* Voice & Tone + Contact */} -
- -
-
- -

Voice & Tone

-
-
-
-

How to describe Emberly

-
    -
  • • Developer-first file sharing platform
  • -
  • • Privacy-focused and open source
  • -
  • • Self-hostable with modern architecture
  • -
  • • Simple, fast, and reliable
  • -
-
-
-

Naming conventions

-
    -
  • • Always capitalize: Emberly
  • -
  • • Don't abbreviate or stylize (not "EMBERLY")
  • -
  • • Reference the project, not "the Emberly team"
  • -
  • • Use "Emberly" alone, not "Emberly app/service"
  • -
-
-
-
-
- - -
-

Need something else?

-

- Contact us for custom assets, interview requests, or bespoke materials. -

-
- {CONTACT_POINTS.map((point) => ( - - ))} -
-
-
-
- - {/* Download CTA */} - -
- -

Ready to go?

-

- Download the complete media kit including high-resolution logos, - color swatches, and additional assets. -

-
- -
-
-
-
-
- ) -} - -type AssetPreviewProps = { - variant: 'primary' | 'icon' | 'wordmark' | 'mono' - theme: 'light' | 'dark' -} - -function AssetPreview({ variant, theme }: AssetPreviewProps) { - const baseClasses = 'flex h-40 w-full items-center justify-center rounded-xl border border-border/30' - - // Use theme-aware backgrounds that adapt to the active theme - const background = - theme === 'dark' - ? 'bg-gradient-to-br from-background via-muted/30 to-background' - : 'bg-gradient-to-br from-card via-accent/10 to-card' - - if (variant === 'wordmark') { - return ( -
- - EMBERLY - -
- ) - } - - if (variant === 'icon') { - return ( -
- Emberly icon -
- ) - } - - if (variant === 'mono') { - return ( -
-
- Emberly monochrome -
-
- ) - } - - // Primary - return ( -
-
- Emberly logo - - Emberly - -
-
- ) -} - -// Helper to convert HSL CSS variable to hex -function hslToHex(h: number, s: number, l: number): string { - s /= 100 - l /= 100 - const a = s * Math.min(l, 1 - l) - const f = (n: number) => { - const k = (n + h / 30) % 12 - const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) - return Math.round(255 * color).toString(16).padStart(2, '0') - } - return `#${f(0)}${f(8)}${f(4)}`.toUpperCase() -} - -// Helper to get luminance for contrast calculation -function getLuminance(hex: string): number { - const rgb = hex.replace('#', '').match(/.{2}/g)?.map(x => parseInt(x, 16) / 255) || [0, 0, 0] - const [r, g, b] = rgb.map(c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)) - return 0.2126 * r + 0.7152 * g + 0.0722 * b -} - -// Video player component that only renders on client to avoid hydration issues -function VideoPlayer({ src }: { src: string }) { - const [mounted, setMounted] = useState(false) - const videoRef = useRef(null) - - useEffect(() => { - setMounted(true) - }, []) - - if (!mounted) { - return ( -
- -
- ) - } - - return ( -
- -
- ) -} - -function ThemeColorPalette() { - const [colors, setColors] = useState<{ name: string; hex: string; rgb: string; usage: string }[]>([]) - const [, forceUpdate] = useState(0) - - useEffect(() => { - const updateColors = () => { - const root = document.documentElement - const computed = getComputedStyle(root) - - const resolved = THEME_COLORS.map(color => { - const raw = computed.getPropertyValue(color.cssVar).trim() - // HSL format: "210 40% 98%" or similar - const parts = raw.split(/\s+/).map(p => parseFloat(p)) - let hex = '#000000' - let rgb = '0, 0, 0' - - if (parts.length >= 3) { - hex = hslToHex(parts[0], parts[1], parts[2]) - // Convert hex to RGB - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - rgb = `${r}, ${g}, ${b}` - } - - return { - name: color.name, - hex, - rgb, - usage: color.usage, - } - }) - - setColors(resolved) - } - - // Initial update - updateColors() - - // Watch for theme changes via class or attribute changes on html element - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.attributeName === 'class' || mutation.attributeName === 'style' || mutation.attributeName === 'data-theme') { - // Small delay to let CSS variables update - setTimeout(updateColors, 50) - break - } - } - }) - - observer.observe(document.documentElement, { attributes: true }) - - return () => observer.disconnect() - }, []) - - if (colors.length === 0) { - return ( -
- {THEME_COLORS.map(color => ( -
-
-
-
-
-
-
- ))} -
- ) - } - - return ( -
- {colors.map(color => { - const isDark = getLuminance(color.hex) < 0.5 - return ( -
-
- - {color.hex} - -
-
-
{color.name}
-
RGB: {color.rgb}
-
{color.usage}
-
-
- ) - })} -
- ) -} diff --git a/app/(main)/press/page.tsx b/app/(main)/press/page.tsx deleted file mode 100644 index 837134d..0000000 --- a/app/(main)/press/page.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import Link from 'next/link' -import Image from 'next/image' - -import { - ArrowRight, - BookOpen, - Calendar, - Download, - ExternalLink, - FileText, - Github, - ImageIcon, - Mail, - Newspaper, - Palette, - Quote, - Shield, - Sparkles, -} from 'lucide-react' - -import { Button } from '@/packages/components/ui/button' -import { Badge } from '@/packages/components/ui/badge' -import HomeShell from '@/packages/components/layout/home-shell' - -// Reusable GlassCard component -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -const PRESS_RESOURCES = [ - { - icon: ImageIcon, - title: 'Media Kit', - description: 'Logos, screenshots, brand colors, and usage guidelines for press and partners.', - href: '/press/media-kit', - label: 'Download assets', - color: 'text-primary', - bg: 'bg-primary/10', - }, - { - icon: FileText, - title: 'Brand Guidelines', - description: 'How to properly represent Emberly in articles, presentations, and media.', - href: '/press/media-kit#guidelines', - label: 'View guidelines', - color: 'text-accent-foreground', - bg: 'bg-accent/50', - }, - { - icon: Palette, - title: 'Design Assets', - description: 'High-resolution logos, icons, and marketing materials.', - href: 'https://github.com/EmberlyOSS/Emberly/releases/latest', - label: 'Download ZIP', - external: true, - color: 'text-muted-foreground', - bg: 'bg-muted/50', - }, -] - -const QUICK_FACTS = [ - { label: 'Founded', value: '2022' }, - { label: 'Type', value: 'Open Source' }, - { label: 'License', value: 'GPL 3.0' }, - { label: 'Team', value: 'Remote first' }, -] - -const CONTACT_POINTS = [ - { - icon: Mail, - label: 'Press inquiries', - value: 'press@embrly.ca', - href: 'mailto:press@embrly.ca', - }, - { - icon: Github, - label: 'Technical questions', - value: 'GitHub Issues', - href: 'https://github.com/EmberlyOSS/Emberly/issues', - }, -] - -import { buildPageMetadata } from '@/packages/lib/embeds/metadata' - -export const metadata = buildPageMetadata({ - title: 'Press & Media', - description: 'Press resources, media kit, and contact information for journalists and media partners.', -}) - -export default function PressPage() { - return ( - -
- {/* Hero Section */} - -
-
-
- - - Press & Media - -

- Press - - Resources - -

-

- Everything you need to write about Emberly. Download our media kit, - access brand assets, or get in touch for interviews and inquiries. -

- -
- - -
-
- - {/* Quick Facts Card */} -
-
-
- -
-

Quick Facts

-
- {QUICK_FACTS.map((fact) => ( -
-
{fact.value}
-
{fact.label}
-
- ))} -
-
- -

- "A lightweight, developer first file sharing platform focused on - privacy, reliability, and simplicity." -

-
-
-
-
-
-
-
- - - {/* Resources Grid */} -
- {PRESS_RESOURCES.map((resource) => ( - -
-
- -
-

{resource.title}

-

- {resource.description} -

-
- -
-
-
- ))} -
- - {/* About Emberly & Contact */} -
- -
-

About Emberly

-
-

- Emberly is an open-source file sharing platform designed for developers, - teams, and communities who value privacy and simplicity. Built with modern - web technologies, it offers a self hostable alternative to traditional - cloud storage services. -

-

- Key features include password-protected files, custom domains, rich embeds, - chunked uploads for large files, and a clean, intuitive dashboard for - content management. The platform is fully open source under the GPL 3.0 license, - allowing anyone to audit, modify, and self host their own instance. -

-

- Founded in 2022, Emberly is maintained by a remote first team of developers - passionate about privacy respecting software. The project welcomes - contributions from the community. -

-
-
- - - Open Source - - - - Privacy First - - - - Since 2022 - -
-
-
- - -
-

Press Contact

-

- For interviews, media inquiries, or partnership opportunities. -

-
- {CONTACT_POINTS.map((contact) => ( - -
- -
-
-
{contact.label}
-
- {contact.value} -
-
- -
- ))} -
-
-

- Response time: Usually within 1-2 business days -

-
-
-
-
- - {/* Logo Preview */} - -
-
-
-

Logo & Brand

-

- Download our logo in various formats. Always use the official assets - when representing Emberly. -

-
-
-
- Emberly Logo -
-
- Emberly Logo Alt -
-
-
-
- - -
-
-
-
- - ) -} diff --git a/app/(main)/pricing/layout.tsx b/app/(main)/pricing/layout.tsx deleted file mode 100644 index 9ccf17e..0000000 --- a/app/(main)/pricing/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function PricingLayout({ children }: { children: React.ReactNode }) { - return <>{children} -} diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index 8d47ccc..f12f371 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -54,14 +54,21 @@ const defaultSetupData: SetupData = { admin: { name: '', email: '', password: '' }, storage: { provider: 'local', - s3: { bucket: '', region: '', accessKeyId: '', secretAccessKey: '', endpoint: '', forcePathStyle: false }, + s3: { + bucket: '', + region: '', + accessKeyId: '', + secretAccessKey: '', + endpoint: '', + forcePathStyle: false, + }, }, registrations: { enabled: true, disabledMessage: '' }, } const STEPS = [ { id: 1, label: 'Admin Account', icon: User }, - { id: 2, label: 'Storage', icon: HardDrive }, + { id: 2, label: 'Storage', icon: HardDrive }, { id: 3, label: 'Registrations', icon: Users }, ] @@ -75,13 +82,25 @@ function StepIndicator({ current }: { current: number }) { return (
-
- {done ? : } +
+ {done ? ( + + ) : ( + + )}
- {s.label} + + {s.label} +
{idx < STEPS.length - 1 && ( -
s.id ? 'bg-primary/50' : 'bg-border/40'}`} /> +
s.id ? 'bg-primary/50' : 'bg-border/40'}`} + /> )}
) @@ -90,7 +109,17 @@ function StepIndicator({ current }: { current: number }) { ) } -function FieldRow({ icon: Icon, label, hint, children }: { icon: React.ElementType; label: string; hint?: string; children: React.ReactNode }) { +function FieldRow({ + icon: Icon, + label, + hint, + children, +}: { + icon: React.ElementType + label: string + hint?: string + children: React.ReactNode +}) { return (
+ ) } diff --git a/app/(marketing)/about/page.tsx b/app/(marketing)/about/page.tsx new file mode 100644 index 0000000..a78ef12 --- /dev/null +++ b/app/(marketing)/about/page.tsx @@ -0,0 +1,470 @@ +import Link from 'next/link' +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/packages/lib/auth' + +import { + ArrowRight, + BookOpen, + Check, + Code2, + Github, + Globe, + Heart, + Lock, + Rocket, + Server, + Shield, + Sparkles, + Users, + Zap, +} from 'lucide-react' + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from '@/packages/components/ui/avatar' +import { Button } from '@/packages/components/ui/button' +import { Badge } from '@/packages/components/ui/badge' +import HomeShell from '@/packages/components/layout/home-shell' + +// Reusable GlassCard component +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
{children}
+ ) +} + +const CORE_VALUES = [ + { + icon: Shield, + title: 'Privacy First', + description: + 'Your files, your control. We never scan, index, or monetize your content.', + color: 'text-chart-1', + bg: 'bg-chart-1/10', + }, + { + icon: Zap, + title: 'Lightning Fast', + description: + 'Optimized upload flows with chunked transfers and edge caching.', + color: 'text-primary', + bg: 'bg-primary/10', + }, + { + icon: Code2, + title: 'Open Source', + description: + 'Fully transparent codebase. Audit it, fork it, contribute to it.', + color: 'text-chart-4', + bg: 'bg-chart-4/10', + }, + { + icon: Server, + title: 'Self-Hostable', + description: 'Deploy on your own infrastructure with Docker or bare metal.', + color: 'text-chart-3', + bg: 'bg-chart-3/10', + }, +] + +const FEATURES = [ + { icon: Lock, label: 'Password Protection' }, + { icon: Globe, label: 'Custom Domains' }, + { icon: Rocket, label: 'Rich Embeds' }, + { icon: Users, label: 'Team Sharing' }, +] + +async function getContributors() { + const { getIntegrations } = await import('@/packages/lib/config') + const integrations = await getIntegrations() + const org = integrations.github?.org || process.env.GITHUB_ORG || 'EmberlyOSS' + const pat = integrations.github?.pat || process.env.GITHUB_PAT + const headers: Record = { + Accept: 'application/vnd.github.v3+json', + } + if (pat) { + headers.Authorization = `token ${pat}` + } + const excludedRepos = new Set( + ['premid', 'activities', 'statuspage', 'peppermint'] + .map((repo) => repo.trim().toLowerCase()) + .filter(Boolean) + ) + + try { + const [membersRes, reposRes] = await Promise.all([ + fetch(`https://api.github.com/orgs/${org}/members?per_page=100`, { + headers, + next: { revalidate: 60 * 60 }, + }), + fetch(`https://api.github.com/orgs/${org}/repos?per_page=100`, { + headers, + next: { revalidate: 60 * 60 }, + }), + ]) + + const membersJson = membersRes.ok ? await membersRes.json() : [] + const reposJson = reposRes.ok ? await reposRes.json() : [] + + const repoNames = Array.isArray(reposJson) + ? reposJson + .map((r: any) => r.name) + .filter( + (name: any) => typeof name === 'string' && name.trim().length > 0 + ) + .filter((name: string) => !excludedRepos.has(name.toLowerCase())) + : [] + + const contribPromises = repoNames.map((name: string) => + fetch( + `https://api.github.com/repos/${org}/${name}/contributors?per_page=100`, + { + headers, + next: { revalidate: 60 * 60 }, + } + ) + .then((r) => (r.ok ? r.json() : [])) + .catch(() => []) + ) + + const contribSettled = await Promise.allSettled(contribPromises) + + const map = new Map() + + if (Array.isArray(membersJson)) { + for (const m of membersJson) { + if (!m || !m.login) continue + map.set(m.login.toLowerCase(), { + login: m.login, + avatar: m.avatar_url, + url: m.html_url || `https://github.com/${m.login}`, + contributions: 0, + orgMember: true, + }) + } + } + + for (const s of contribSettled) { + if (s.status !== 'fulfilled') continue + const arr = s.value + if (!Array.isArray(arr)) continue + for (const c of arr) { + if (!c || !c.login) continue + const key = c.login.toLowerCase() + const contributions = + typeof c.contributions === 'number' ? c.contributions : 0 + const existing = map.get(key) + if (existing) { + existing.contributions = (existing.contributions || 0) + contributions + existing.avatar = existing.avatar || c.avatar_url + existing.url = existing.url || c.html_url + map.set(key, existing) + } else { + map.set(key, { + login: c.login, + avatar: c.avatar_url, + url: c.html_url, + contributions, + orgMember: false, + }) + } + } + } + + const result = Array.from(map.values()).sort((a: any, b: any) => { + if (a.orgMember === b.orgMember) + return (b.contributions || 0) - (a.contributions || 0) + return a.orgMember ? -1 : 1 + }) + + return result + } catch (e) { + console.error('Failed to load contributors', e) + return [] + } +} + +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' + +export const metadata = buildPageMetadata({ + title: 'About Us', + description: + 'Emberly is an open source platform for file sharing, URL shortening, and talent discovery. Privacy-first, developer-friendly, and built for teams.', +}) + +export default async function AboutPage() { + const session = await getServerSession(authOptions) + const contributors = await getContributors() + + return ( + +
+ {/* Hero Section */} + +
+
+
+ + + Open Source + +

+ More than hosting, + + it's a platform. + +

+

+ Emberly started as a privacy-first file sharing tool for + developers and has grown into a full platform: file hosting, + URL shortening, and Nexium our built in talent discovery and + squad collaboration network. Self-hostable, open source, and + built for teams. +

+ +
+ + +
+
+ + {/* Mission Card */} +
+
+
+ +
+
+
+ +
+

Our Mission

+
+

+ Build and share more than just files. Emberly gives you + the tools to host content, shorten links, discover + talent, and collaborate with squads — all in one open + source platform. +

+
+ {[ + 'Fast uploads with chunked and presigned flows', + 'URL shortener with analytics and custom slugs', + 'Discovery: talent profiles, squads, and discovery', + ].map((item, i) => ( +
+
+ +
+ + {item} + +
+ ))} +
+
+
+
+
+
+
+ + + {/* Core Values */} +
+ {CORE_VALUES.map((value) => ( + +
+
+ +
+

{value.title}

+

+ {value.description} +

+
+
+ ))} +
+ + {/* Why Emberly & Features */} +
+ +
+

Why Emberly?

+

+ We built Emberly because existing file sharing solutions are + either too complex, too expensive, or don't respect your + privacy. Emberly prioritizes speed, privacy, and a pleasant UX. + It's built so teams can self-host without wrestling with + complex infrastructure. +

+

+ As Emberly has grown, so has the scope. File hosting is the core + — but now we also offer URL shortening, rich embed previews, and + Discovery: a talent discovery and squad collaboration platform. + Whether you're a developer, creator, or community builder, + Emberly scales with you while keeping your data under your + control. +

+
+ {FEATURES.map((feature) => ( +
+ + {feature.label} +
+ ))} +
+
+
+ + +
+

Get Involved

+

+ Contributions, bug reports, and ideas are welcome. Check the + repo for issues and contributor guidelines, or join our Discord + to chat with the community. +

+
+ + +
+
+
+
+ + {/* Contributors Section */} + {contributors.length > 0 && ( + +
+
+
+

Contributors

+

+ The amazing people who make Emberly possible +

+
+ + {contributors.length} contributors + +
+
+ {contributors.map((c) => ( + +
+ + + + {c.login?.[0]?.toUpperCase() || 'U'} + + + {c.orgMember && ( +
+ +
+ )} + + ))} +
+

+ + indicates organization member +

+
+ + )} + + {/* CTA Section */} + +
+

+ Ready to get started? +

+

+ Join thousands of developers, creators, and teams who use Emberly + to share, build, and connect. +

+
+ {session ? ( + + ) : ( + + )} + +
+
+
+
+ + ) +} diff --git a/app/(marketing)/applications/[type]/page.tsx b/app/(marketing)/applications/[type]/page.tsx new file mode 100644 index 0000000..3760ad0 --- /dev/null +++ b/app/(marketing)/applications/[type]/page.tsx @@ -0,0 +1,80 @@ +import { notFound, redirect } from 'next/navigation' + +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/packages/lib/auth' +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' +import { ApplicationForm } from '@/packages/components/applications/application-form' +import HomeShell from '@/packages/components/layout/home-shell' + +type ApplicationType = 'staff' | 'partner' | 'verification' | 'ban-appeal' + +const VALID_TYPES: ApplicationType[] = [ + 'staff', + 'partner', + 'verification', + 'ban-appeal', +] + +const TYPE_META: Record< + ApplicationType, + { title: string; description: string } +> = { + staff: { + title: 'Staff Application', + description: 'Apply to join the Emberly team.', + }, + partner: { + title: 'Partner Application', + description: 'Apply to become an Emberly partner.', + }, + verification: { + title: 'Verification Request', + description: 'Request profile verification on Emberly.', + }, + 'ban-appeal': { + title: 'Ban Appeal', + description: 'Submit an appeal to have your account suspension reviewed.', + }, +} + +interface PageProps { + params: Promise<{ type: string }> +} + +export async function generateMetadata({ params }: PageProps) { + const { type } = await params + if (!VALID_TYPES.includes(type as ApplicationType)) return {} + const meta = TYPE_META[type as ApplicationType] + return buildPageMetadata({ title: meta.title, description: meta.description }) +} + +export default async function ApplicationTypePage({ params }: PageProps) { + const { type } = await params + + if (!VALID_TYPES.includes(type as ApplicationType)) { + notFound() + } + + const session = await getServerSession(authOptions) + if (!session?.user) { + redirect('/auth/login') + } + + const applicationType = type as ApplicationType + const meta = TYPE_META[applicationType] + + return ( + +
+
+

+ {meta.title} +

+

{meta.description}

+
+ +
+
+ ) +} diff --git a/app/(marketing)/applications/page.tsx b/app/(marketing)/applications/page.tsx new file mode 100644 index 0000000..bdcd02e --- /dev/null +++ b/app/(marketing)/applications/page.tsx @@ -0,0 +1,173 @@ +import Link from 'next/link' + +import { getServerSession } from 'next-auth' +import { + ArrowRight, + BadgeCheck, + Briefcase, + Handshake, + LogIn, + Scale, +} from 'lucide-react' + +import { authOptions } from '@/packages/lib/auth' +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' +import { Button } from '@/packages/components/ui/button' +import { Badge } from '@/packages/components/ui/badge' +import HomeShell from '@/packages/components/layout/home-shell' + +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
{children}
+ ) +} + +export const metadata = buildPageMetadata({ + title: 'Applications', + description: + 'Apply to join the Emberly team, become a partner, request verification, or appeal a ban.', +}) + +const APPLICATION_CARDS = [ + { + type: 'staff', + icon: Briefcase, + title: 'Join Our Team', + description: + "We're looking for passionate people who want to make a difference.", + color: 'text-chart-1', + bg: 'bg-chart-1/10', + badge: 'Staff Application', + }, + { + type: 'partner', + icon: Handshake, + title: 'Partner With Us', + description: + 'Collaborate and cross-promote. Grow your audience alongside Emberly.', + color: 'text-chart-3', + bg: 'bg-chart-3/10', + badge: 'Partnership', + }, + { + type: 'verification', + icon: BadgeCheck, + title: 'Get Verified', + description: + 'Request verification for your profile to build trust with your audience.', + color: 'text-primary', + bg: 'bg-primary/10', + badge: 'Verification', + }, + { + type: 'ban-appeal', + icon: Scale, + title: 'Appeal a Ban', + description: + 'Request review of an account suspension by a senior team member.', + color: 'text-chart-5', + bg: 'bg-chart-5/10', + badge: 'Ban Appeal', + }, +] + +export default async function ApplicationsPage() { + const session = await getServerSession(authOptions) + + return ( + +
+ {/* Hero */} + +
+
+ + + Applications + +

+ Get involved with + + Emberly. + +

+

+ Whether you want to join our team, partner with us, get + verified, or appeal a decision submit your application below. +

+
+
+
+ + {!session?.user ? ( + /* Not logged in */ + +
+
+ +
+
+

Sign in to apply

+

+ You must be logged in to submit an application. +

+
+ +
+
+ ) : ( + /* Logged in — show cards */ +
+ {APPLICATION_CARDS.map((card) => ( + +
+
+
+ +
+
+
+

{card.title}

+ + {card.badge} + +
+

+ {card.description} +

+
+
+
+ +
+
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/app/(main)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx similarity index 100% rename from app/(main)/blog/[slug]/page.tsx rename to app/(marketing)/blog/[slug]/page.tsx diff --git a/app/(main)/blog/page.tsx b/app/(marketing)/blog/page.tsx similarity index 100% rename from app/(main)/blog/page.tsx rename to app/(marketing)/blog/page.tsx diff --git a/app/(marketing)/changelogs/page.tsx b/app/(marketing)/changelogs/page.tsx new file mode 100644 index 0000000..be86f4b --- /dev/null +++ b/app/(marketing)/changelogs/page.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import ChangelogList from '@/packages/components/changelogs/ChangelogList' +import { DashboardWrapper } from '@/packages/components/dashboard/dashboard-wrapper' +import { getConfig } from '@/packages/lib/config' +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' + +export const metadata = buildPageMetadata({ + title: 'Changelogs', + description: + 'View releases across all of the Emberly services. Powered by GitHub Releases!', +}) + +export default async function Page() { + const config = await getConfig() + const { value, unit } = config.settings.general.storage.maxUploadSize + const maxSizeBytes = + value * (unit === 'GB' ? 1024 * 1024 * 1024 : 1024 * 1024) + + return ( + +
+
+
+
+
+

+ Changelogs +

+

+ View releases across all of the Emberly services. Powered by + GitHub Releases! +

+
+
+
+
+ + +
+
+ ) +} diff --git a/app/(marketing)/contact/page.tsx b/app/(marketing)/contact/page.tsx new file mode 100644 index 0000000..fe24757 --- /dev/null +++ b/app/(marketing)/contact/page.tsx @@ -0,0 +1,329 @@ +import Link from 'next/link' + +import { + ArrowRight, + BookOpen, + Bug, + Clock, + Github, + Globe, + Headphones, + Mail, + MessageCircle, + MessagesSquare, + Newspaper, + Shield, + Twitter, + Users, + Zap, +} from 'lucide-react' + +import { Button } from '@/packages/components/ui/button' +import { Badge } from '@/packages/components/ui/badge' +import HomeShell from '@/packages/components/layout/home-shell' + +// Reusable GlassCard component +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
{children}
+ ) +} + +const CONTACT_CHANNELS = [ + { + icon: Github, + title: 'GitHub Issues', + description: + 'Report bugs, request features, or contribute to the codebase.', + href: 'https://github.com/EmberlyOSS/Emberly/issues', + label: 'Open an issue', + external: true, + color: 'text-foreground', + bg: 'bg-foreground/10', + }, + { + icon: MessagesSquare, + title: 'GitHub Discussions', + description: 'Community help, ideas, and design discussion.', + href: 'https://github.com/orgs/EmberlyOSS/discussions', + label: 'Join discussions', + external: true, + color: 'text-chart-3', + bg: 'bg-chart-3/10', + }, + { + icon: Users, + title: 'Discord Community', + description: 'Real-time chat, support, and community hangouts.', + href: '/discord', + label: 'Join Discord', + external: false, + color: 'text-chart-1', + bg: 'bg-chart-1/10', + }, + { + icon: Twitter, + title: 'Twitter / X', + description: 'Follow for announcements and quick updates.', + href: 'https://twitter.com/TryEmberly', + label: 'Follow us', + external: true, + color: 'text-chart-5', + bg: 'bg-chart-5/10', + }, +] + +const EMAIL_CONTACTS = [ + { + icon: Globe, + label: 'General Inquiries', + email: 'hello@embrly.ca', + description: 'Questions about Emberly or just want to say hi', + }, + { + icon: Headphones, + label: 'Support', + email: 'support@embrly.ca', + description: 'Technical help and account issues', + }, + { + icon: Newspaper, + label: 'Press & Partnerships', + email: 'press@embrly.ca', + description: 'Media inquiries and collaboration opportunities', + }, + { + icon: Shield, + label: 'Security', + email: 'security@embrly.ca', + description: 'Report vulnerabilities responsibly', + }, +] + +const QUICK_LINKS = [ + { icon: BookOpen, label: 'Blog', href: '/blog' }, + { + icon: Bug, + label: 'Bug Reports', + href: 'https://github.com/EmberlyOSS/Emberly/issues/new?template=bug_report.md', + }, + { + icon: Zap, + label: 'Feature Requests', + href: 'https://github.com/EmberlyOSS/Emberly/issues/new?template=feature_request.md', + }, +] + +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' + +export const metadata = buildPageMetadata({ + title: 'Contact', + description: + "Get in touch with the Emberly team - we'd love to hear from you.", +}) + +export default function ContactPage() { + return ( + +
+ {/* Hero Section */} + +
+
+ + + Get in Touch + +

+ We'd love to + + hear from you. + +

+

+ Questions, feedback, partnership inquiries, or just want to say + hi? Choose your preferred way to reach us below. +

+
+
+
+ + {/* Main Content Grid */} +
+ {/* Contact Channels */} +
+

Community & Support

+
+ {CONTACT_CHANNELS.map((channel) => ( + +
+
+
+ +
+
+

+ {channel.title} +

+

+ {channel.description} +

+
+
+
+ +
+
+
+ ))} +
+
+ + {/* Email Contacts */} +
+

Email Us

+ +
+ {EMAIL_CONTACTS.map((contact, index) => ( +
+
+
+ +
+
+
+ {contact.label} +
+ + {contact.email} + +

+ {contact.description} +

+
+
+ {index < EMAIL_CONTACTS.length - 1 && ( +
+ )} +
+ ))} +
+ + + {/* Response Time */} + +
+
+
+ +
+
+
Response Time
+

+ Usually within 1-2 business days +

+
+
+
+
+
+
+ + {/* Quick Links */} + +
+
+
+

Quick Links

+

+ Looking for something specific? +

+
+
+ {QUICK_LINKS.map((link) => ( + + ))} +
+
+
+
+ + {/* CTA Section */} + +
+ +

+ Can't find what you're looking for? +

+

+ Check out our blog for the latest updates and guides, or reach out + directly and we'll point you in the right direction. +

+
+ + +
+
+
+
+ + ) +} diff --git a/app/(main)/discord/page.tsx b/app/(marketing)/discord/page.tsx similarity index 98% rename from app/(main)/discord/page.tsx rename to app/(marketing)/discord/page.tsx index 7acb1b4..edb780b 100644 --- a/app/(main)/discord/page.tsx +++ b/app/(marketing)/discord/page.tsx @@ -18,6 +18,7 @@ import { import { Badge } from '@/packages/components/ui/badge' import { Button } from '@/packages/components/ui/button' import HomeShell from '@/packages/components/layout/home-shell' +import { DISCORD_INVITE_URL } from '@/packages/lib/constants/site' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' export const metadata = buildPageMetadata({ @@ -129,7 +130,7 @@ export default function DiscordPage() {
@@ -192,7 +210,8 @@ export default async function NexiumDiscoveryPage({ ) : (
{profiles.map((profile) => { - const displayName = profile.user.fullName ?? profile.user.name ?? 'Unknown' + const displayName = + profile.user.fullName ?? profile.user.name ?? 'Unknown' const username = profile.user.name ?? profile.user.urlId const profileHref = `/user/${username}` const topSkills = profile.skills.slice(0, 4) @@ -223,16 +242,26 @@ export default async function NexiumDiscoveryPage({
)}
- - {NEXIUM_AVAILABILITY_LABELS[profile.availability as keyof typeof NEXIUM_AVAILABILITY_LABELS]} + + { + NEXIUM_AVAILABILITY_LABELS[ + profile.availability as keyof typeof NEXIUM_AVAILABILITY_LABELS + ] + }
{/* Name + title */}
-

{displayName}

+

+ {displayName} +

{profile.title && ( -

{profile.title}

+

+ {profile.title} +

)}
@@ -247,7 +276,9 @@ export default async function NexiumDiscoveryPage({ {(profile as any).location && (
- {(profile as any).location} + + {(profile as any).location} +
)} @@ -278,7 +309,9 @@ export default async function NexiumDiscoveryPage({ key={tag} className="text-[10px] px-2 py-0.5 rounded-full bg-muted/20 text-muted-foreground border border-muted/30" > - {NEXIUM_LOOKING_FOR_LABELS[tag as NexiumLookingFor] ?? tag} + {NEXIUM_LOOKING_FOR_LABELS[ + tag as NexiumLookingFor + ] ?? tag} ))}
diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..42f5b34 --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation' + +import { getServerSession } from 'next-auth' + +import ConditionalBaseNav from '@/packages/components/layout/conditional-base-nav' +import FooterWrapper from '@/packages/components/layout/footer-wrapper' +import { authOptions } from '@/packages/lib/auth' +import { getConfig } from '@/packages/lib/config' +import { isCloudEnabled } from '@/packages/lib/config/env' + +export default async function MarketingLayout({ + children, +}: { + children: React.ReactNode +}) { + if (!isCloudEnabled()) { + const session = await getServerSession(authOptions) + redirect(session?.user ? '/dashboard' : '/auth/login') + } + + const config = await getConfig() + const showFooter = config.settings.general.credits.showFooter + + return ( +
+ +
{children}
+ +
+ ) +} diff --git a/app/(main)/leaderboard/page.tsx b/app/(marketing)/leaderboard/page.tsx similarity index 72% rename from app/(main)/leaderboard/page.tsx rename to app/(marketing)/leaderboard/page.tsx index 1af298d..038b5ef 100644 --- a/app/(main)/leaderboard/page.tsx +++ b/app/(marketing)/leaderboard/page.tsx @@ -3,9 +3,9 @@ import HomeShell from '@/packages/components/layout/home-shell' import { Metadata } from 'next' export const metadata: Metadata = { - title: 'Leaderboard | Emberly', - description: 'Top contributors and power users in the Emberly community.', - } + title: 'Leaderboard | Emberly', + description: 'Top contributors and power users in the Emberly community.', +} export default function LeaderboardPage() { return ( diff --git a/app/(marketing)/legal/[...slug]/page.tsx b/app/(marketing)/legal/[...slug]/page.tsx new file mode 100644 index 0000000..e585e0a --- /dev/null +++ b/app/(marketing)/legal/[...slug]/page.tsx @@ -0,0 +1,62 @@ +import { notFound } from 'next/navigation' +import type { Metadata } from 'next' + +import LegalArticle from '@/packages/components/legal/LegalArticle' +import { getLegalBySlug } from '@/packages/lib/legal/service' + +type Params = { slug: string[] } + +function normalizeSlug(slugParts: string[]) { + return slugParts.join('/').toLowerCase() +} + +export async function generateMetadata({ + params, +}: { + params: Promise +}): Promise { + const resolvedParams = await params + const slug = Array.isArray(resolvedParams.slug) + ? resolvedParams.slug + : [resolvedParams.slug] + const slugKey = normalizeSlug(slug) + + const legal = await getLegalBySlug(slugKey, true) + + if (!legal) { + return { + title: 'Legal Document Not Found', + description: 'The requested legal document could not be found.', + } + } + + return { + title: legal.title, + description: + legal.excerpt || `${legal.title} - Emberly legal documentation.`, + } +} + +export default async function LegalCatchallPage({ + params, +}: { + params: Promise +}) { + const resolvedParams = await params + const slug = Array.isArray(resolvedParams.slug) + ? resolvedParams.slug + : [resolvedParams.slug] + const slugKey = normalizeSlug(slug) + + const legal = await getLegalBySlug(slugKey, true) + if (!legal) return notFound() + + return ( + + ) +} diff --git a/app/(main)/legal/page.tsx b/app/(marketing)/legal/page.tsx similarity index 69% rename from app/(main)/legal/page.tsx rename to app/(marketing)/legal/page.tsx index 6cfbd83..bc22cd8 100644 --- a/app/(main)/legal/page.tsx +++ b/app/(marketing)/legal/page.tsx @@ -1,21 +1,35 @@ +import { redirect } from 'next/navigation' + import Link from 'next/link' import PageShell from '@/packages/components/layout/PageShell' -import { Alert, AlertDescription, AlertTitle } from '@/packages/components/ui/alert' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/packages/components/ui/alert' +import { isCloudEnabled } from '@/packages/lib/config/env' import { buildPageMetadata } from '@/packages/lib/embeds/metadata' import { listLegal } from '@/packages/lib/legal/service' export const metadata = buildPageMetadata({ title: 'Legal', - description: 'Emberly legal hub: Terms of Service, Privacy Policy, Cookie Policy, Security Policy, and GDPR information.', + description: + 'Emberly legal hub: Terms of Service, Privacy Policy, Cookie Policy, Security Policy, and GDPR information.', }) function formatUpdated(date?: Date | null) { if (!date) return null - return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) } export default async function LegalHubPage() { + if (!isCloudEnabled()) redirect('/') + const legalPages = await listLegal({ publishedOnly: true, limit: 100 }) const rows = legalPages.map((page) => ({ href: `/legal/${page.slug}`, @@ -25,7 +39,11 @@ export default async function LegalHubPage() { })) return ( - +
{rows.map((row) => ( @@ -34,8 +52,12 @@ export default async function LegalHubPage() {
-

{row.title}

-

{row.summary || 'Read the full document.'}

+

+ {row.title} +

+

+ {row.summary || 'Read the full document.'} +

{row.updatedAt ?? 'Up to date'} diff --git a/app/(main)/page.tsx b/app/(marketing)/page.tsx similarity index 78% rename from app/(main)/page.tsx rename to app/(marketing)/page.tsx index 3f66889..7eafac5 100644 --- a/app/(main)/page.tsx +++ b/app/(marketing)/page.tsx @@ -46,7 +46,13 @@ export async function generateMetadata(): Promise { } // Reusable GlassCard component -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { return (
{children} @@ -65,42 +71,48 @@ const FEATURES = [ { icon: ShieldCheck, title: 'Secure by Design', - description: 'End-to-end encryption, optional password protection, and configurable file expirations.', + description: + 'End-to-end encryption, optional password protection, and configurable file expirations.', color: 'text-chart-1', bg: 'bg-chart-1/10', }, { icon: Zap, title: 'Lightning Fast', - description: 'CDN-backed delivery with chunked uploads for large files and instant short URLs.', + description: + 'CDN-backed delivery with chunked uploads for large files and instant short URLs.', color: 'text-primary', bg: 'bg-primary/10', }, { icon: Sparkles, title: 'Rich Embeds', - description: 'Beautiful previews on Discord, Twitter, Slack, and more with customizable metadata.', + description: + 'Beautiful previews on Discord, Twitter, Slack, and more with customizable metadata.', color: 'text-chart-3', bg: 'bg-chart-3/10', }, { icon: Globe, title: 'Custom Domains', - description: 'Bring your own domain to serve files under your brand with full SSL support.', + description: + 'Bring your own domain to serve files under your brand with full SSL support.', color: 'text-chart-4', bg: 'bg-chart-4/10', }, { icon: Code2, title: 'Developer Friendly', - description: 'Simple REST API, ShareX/Flameshot support, and webhook integrations.', + description: + 'Simple REST API, ShareX/Flameshot support, and webhook integrations.', color: 'text-chart-5', bg: 'bg-chart-5/10', }, { icon: Users, title: 'Team Ready', - description: 'Collaborate with teammates, manage permissions, and track usage analytics.', + description: + 'Collaborate with teammates, manage permissions, and track usage analytics.', color: 'text-chart-2', bg: 'bg-chart-2/10', }, @@ -111,46 +123,55 @@ const HOW_IT_WORKS = [ step: '01', icon: Upload, title: 'Upload', - description: 'Drag & drop files or use our API. Supports files up to your configured limit.', + description: + 'Drag & drop files or use our API. Supports files up to your configured limit.', }, { step: '02', icon: Share2, title: 'Share', - description: 'Get a short link instantly. Set expiration, password, or visibility.', + description: + 'Get a short link instantly. Set expiration, password, or visibility.', }, { step: '03', icon: Rocket, title: 'Done', - description: 'Your file is live. Track views, manage access, and export anytime.', + description: + 'Your file is live. Track views, manage access, and export anytime.', }, ] const FAQ_ITEMS = [ { question: 'How do I upload files?', - answer: 'Create an account, then drag & drop files into the dashboard or use our API with tools like ShareX.', + answer: + 'Create an account, then drag & drop files into the dashboard or use our API with tools like ShareX.', }, { question: 'Are my files private?', - answer: 'Files are private by default. You control visibility, expiration, and password protection.', + answer: + 'Files are private by default. You control visibility, expiration, and password protection.', }, { question: 'Can I use a custom domain?', - answer: 'Yes! Point your DNS to Emberly and add the domain in settings to serve files under your brand.', + answer: + 'Yes! Point your DNS to Emberly and add the domain in settings to serve files under your brand.', }, { question: 'What file sizes are supported?', - answer: 'Spark (free) supports up to 500 MB per file. Paid plans increase this up to unlimited on Ember and Enterprise tiers.', + answer: + 'Spark (free) supports up to 500 MB per file. Paid plans increase this up to unlimited on Ember and Enterprise tiers.', }, { question: 'Is there an API?', - answer: 'Yes, we have a full REST API with documentation. Perfect for automation and integrations.', + answer: + 'Yes, we have a full REST API with documentation. Perfect for automation and integrations.', }, { question: 'Can I self-host Emberly?', - answer: 'Absolutely! Emberly is open source. Check our GitHub for deployment guides.', + answer: + 'Absolutely! Emberly is open source. Check our GitHub for deployment guides.', }, ] @@ -175,7 +196,10 @@ export default async function HomePage() { include: { user: { select: { id: true, name: true, urlId: true } } }, }) - const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || process.env.NEXTAUTH_URL || 'https://embrly.ca' + const BASE_URL = + process.env.NEXT_PUBLIC_APP_URL || + process.env.NEXTAUTH_URL || + 'https://embrly.ca' const jsonLd = { '@context': 'https://schema.org', @@ -185,7 +209,8 @@ export default async function HomePage() { '@id': `${BASE_URL}/#website`, url: BASE_URL, name: 'Emberly', - description: 'Open-source file sharing, URL shortening, and talent discovery platform.', + description: + 'Open-source file sharing, URL shortening, and talent discovery platform.', potentialAction: { '@type': 'SearchAction', target: { @@ -205,9 +230,7 @@ export default async function HomePage() { '@type': 'ImageObject', url: `${BASE_URL}/icon.svg`, }, - sameAs: [ - 'https://github.com/EmberlyOSS/Emberly', - ], + sameAs: ['https://github.com/EmberlyOSS/Emberly'], contactPoint: { '@type': 'ContactPoint', url: `${BASE_URL}/contact`, @@ -260,14 +283,13 @@ export default async function HomePage() {

Share Files. - - Build Together. - + Build Together.

- The open-source platform for secure file sharing and team collaboration. - Upload, manage, and share content with custom domains, rich embeds, and built-in talent discovery. + The open-source platform for secure file sharing and team + collaboration. Upload, manage, and share content with custom + domains, rich embeds, and built-in talent discovery.

{/* CTA Buttons */} @@ -287,7 +309,12 @@ export default async function HomePage() { )} - @@ -343,22 +372,35 @@ export default async function HomePage() {
-

project-assets.zip

-

2.4 MB • Uploaded just now

+

+ project-assets.zip +

+

+ 2.4 MB • Uploaded just now +

{/* Status */}
- + Uploaded - + Expires in 7 days - + Password Protected @@ -370,7 +412,11 @@ export default async function HomePage() { Download - @@ -392,19 +438,27 @@ export default async function HomePage() { Built for teams and builders

- Everything you need to share, collaborate, and grow secure file hosting, - custom domains, URL shortening, rich embeds, and team collaboration tools. + Everything you need to share, collaborate, and grow secure file + hosting, custom domains, URL shortening, rich embeds, and team + collaboration tools.

{FEATURES.map((feature) => ( - +
-
+
-

{feature.title}

+

+ {feature.title} +

{feature.description}

@@ -432,7 +486,10 @@ export default async function HomePage() {
{HOW_IT_WORKS.map((item, index) => ( -
+
{/* Connector line (between steps) */} {index < HOW_IT_WORKS.length - 1 && ( <> @@ -453,7 +510,9 @@ export default async function HomePage() { {/* Content */}
- {item.step} + + {item.step} +

{item.title}

{item.description} @@ -520,8 +579,9 @@ export default async function HomePage() { Ready to get started?

- Join developers and teams who use Emberly for secure file sharing, - collaboration, and talent discovery. Free to start, no credit card required. + Join developers and teams who use Emberly for secure file + sharing, collaboration, and talent discovery. Free to start, no + credit card required.

{session ? ( @@ -539,10 +599,13 @@ export default async function HomePage() { )} -
diff --git a/app/(marketing)/press/media-kit/layout.tsx b/app/(marketing)/press/media-kit/layout.tsx new file mode 100644 index 0000000..daaad8c --- /dev/null +++ b/app/(marketing)/press/media-kit/layout.tsx @@ -0,0 +1,15 @@ +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' + +export const metadata = buildPageMetadata({ + title: 'Media Kit', + description: + 'Brand assets, logos, colors, and guidelines for press and partners.', +}) + +export default function MediaKitLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/app/(marketing)/press/media-kit/page.tsx b/app/(marketing)/press/media-kit/page.tsx new file mode 100644 index 0000000..ac12a8d --- /dev/null +++ b/app/(marketing)/press/media-kit/page.tsx @@ -0,0 +1,680 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import Image from 'next/image' +import Link from 'next/link' + +import { + ArrowLeft, + BookOpen, + Check, + Copy, + Download, + ExternalLink, + Github, + Mail, + Palette, + Play, + Shield, + Sparkles, + Type, + Video, + X, +} from 'lucide-react' + +import { Button } from '@/packages/components/ui/button' +import { Badge } from '@/packages/components/ui/badge' +import HomeShell from '@/packages/components/layout/home-shell' + +// Reusable GlassCard component +function GlassCard({ + children, + className = '', + id, +}: { + children: React.ReactNode + className?: string + id?: string +}) { + return ( +
+ {children} +
+ ) +} + +const BRAND_ASSETS = [ + { + title: 'Primary Logo', + description: 'Full-color logo for light and dark backgrounds.', + preview: 'primary', + theme: 'dark', + download: '/icon.svg', + }, + { + title: 'Icon Mark', + description: 'Minimal ember icon for favicons and avatars.', + preview: 'icon', + theme: 'light', + download: '/icon.svg', + }, + { + title: 'Wordmark', + description: 'Text-only logo for inline use and headers.', + preview: 'wordmark', + theme: 'dark', + download: '/icon.svg', + }, + { + title: 'Monochrome', + description: 'Single-color version for limited color contexts.', + preview: 'mono', + theme: 'light', + download: '/icon.svg', + }, +] + +// Theme-aware colors that pull from CSS variables +const THEME_COLORS = [ + { name: 'Primary', cssVar: '--primary', usage: 'Brand color, CTAs, links' }, + { name: 'Accent', cssVar: '--accent', usage: 'Highlights, badges' }, + { name: 'Background', cssVar: '--background', usage: 'Page backgrounds' }, + { name: 'Card', cssVar: '--card', usage: 'Card surfaces' }, + { name: 'Muted', cssVar: '--muted', usage: 'Subtle backgrounds' }, + { name: 'Foreground', cssVar: '--foreground', usage: 'Primary text' }, +] + +const TYPOGRAPHY = [ + { + name: 'Headings', + family: 'Inter', + weight: 'Bold (700-800)', + usage: 'Page titles, section headers', + }, + { + name: 'Body', + family: 'Inter', + weight: 'Regular (400)', + usage: 'Paragraphs, descriptions', + }, + { + name: 'UI Elements', + family: 'Inter', + weight: 'Medium (500)', + usage: 'Buttons, labels, navigation', + }, +] + +const USAGE_DOS = [ + 'Use official logos from this kit', + 'Maintain clear space around the logo', + 'Use on appropriate contrast backgrounds', + 'Scale proportionally', +] + +const USAGE_DONTS = [ + 'Stretch or distort the logo', + 'Change brand colors', + 'Add effects like shadows or gradients', + 'Use on busy or clashing backgrounds', +] + +const PROMO_VIDEOS = [ + { + title: 'Site Preview', + description: 'Quick overview of the Emberly interface and main features.', + src: '/videos/site-preview-ad.mp4', + duration: '~15s', + }, + { + title: 'Upload Flow', + description: 'Demonstration of the seamless file upload experience.', + src: '/videos/uploading-ad.mp4', + duration: '~10s', + }, +] + +const CONTACT_POINTS = [ + { + label: 'Press inquiries', + href: 'mailto:press@embrly.ca', + icon: Mail, + }, + { + label: 'GitHub Issues', + href: 'https://github.com/EmberlyOSS/Emberly/issues', + icon: Github, + }, +] + +const KIT_DOWNLOAD = '/emberly-media-kit.zip' + +export default function MediaKitPage() { + return ( + +
+ {/* Header */} +
+
+ +

+ Media Kit +

+

+ Official brand assets, color palette, and usage guidelines. + Everything you need to represent Emberly correctly. +

+
+
+ +
+
+ + {/* Logo Assets */} +
+
+ +

Logo Assets

+
+
+ {BRAND_ASSETS.map((asset) => ( + +
+
+
+

{asset.title}

+

+ {asset.description} +

+
+ +
+ +
+
+ ))} +
+
+ + {/* Promotional Videos */} +
+ + {/* Color Palette */} + +
+
+ +

Theme Color Palette

+
+

+ Colors adapt to the active theme. These are the current + theme's colors. +

+ +
+
+ + {/* Typography */} + +
+ + + {/* Usage Guidelines */} +
+ +
+
+ +

Do's

+
+
    + {USAGE_DOS.map((item) => ( +
  • +
    + +
    + + {item} + +
  • + ))} +
+
+
+ + +
+
+ +

Don'ts

+
+
    + {USAGE_DONTS.map((item) => ( +
  • +
    + +
    + + {item} + +
  • + ))} +
+
+
+
+ + {/* Voice & Tone + Contact */} +
+ +
+
+ +

Voice & Tone

+
+
+
+

How to describe Emberly

+
    +
  • • Developer-first file sharing platform
  • +
  • • Privacy-focused and open source
  • +
  • • Self-hostable with modern architecture
  • +
  • • Simple, fast, and reliable
  • +
+
+
+

Naming conventions

+
    +
  • + • Always capitalize: Emberly +
  • +
  • • Don't abbreviate or stylize (not "EMBERLY")
  • +
  • • Reference the project, not "the Emberly team"
  • +
  • • Use "Emberly" alone, not "Emberly app/service"
  • +
+
+
+
+
+ + +
+

Need something else?

+

+ Contact us for custom assets, interview requests, or bespoke + materials. +

+
+ {CONTACT_POINTS.map((point) => ( + + ))} +
+
+
+
+ + {/* Download CTA */} + +
+ +

Ready to go?

+

+ Download the complete media kit including high-resolution logos, + color swatches, and additional assets. +

+
+ +
+
+
+
+ + ) +} + +type AssetPreviewProps = { + variant: 'primary' | 'icon' | 'wordmark' | 'mono' + theme: 'light' | 'dark' +} + +function AssetPreview({ variant, theme }: AssetPreviewProps) { + const baseClasses = + 'flex h-40 w-full items-center justify-center rounded-xl border border-border/30' + + // Use theme-aware backgrounds that adapt to the active theme + const background = + theme === 'dark' + ? 'bg-gradient-to-br from-background via-muted/30 to-background' + : 'bg-gradient-to-br from-card via-accent/10 to-card' + + if (variant === 'wordmark') { + return ( +
+ + EMBERLY + +
+ ) + } + + if (variant === 'icon') { + return ( +
+ Emberly icon +
+ ) + } + + if (variant === 'mono') { + return ( +
+
+ Emberly monochrome +
+
+ ) + } + + // Primary + return ( +
+
+ Emberly logo + Emberly +
+
+ ) +} + +// Helper to convert HSL CSS variable to hex +function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => { + const k = (n + h / 30) % 12 + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color) + .toString(16) + .padStart(2, '0') + } + return `#${f(0)}${f(8)}${f(4)}`.toUpperCase() +} + +// Helper to get luminance for contrast calculation +function getLuminance(hex: string): number { + const rgb = hex + .replace('#', '') + .match(/.{2}/g) + ?.map((x) => parseInt(x, 16) / 255) || [0, 0, 0] + const [r, g, b] = rgb.map((c) => + c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + ) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +// Video player component that only renders on client to avoid hydration issues +function VideoPlayer({ src }: { src: string }) { + const [mounted, setMounted] = useState(false) + const videoRef = useRef(null) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} + +function ThemeColorPalette() { + const [colors, setColors] = useState< + { name: string; hex: string; rgb: string; usage: string }[] + >([]) + const [, forceUpdate] = useState(0) + + useEffect(() => { + const updateColors = () => { + const root = document.documentElement + const computed = getComputedStyle(root) + + const resolved = THEME_COLORS.map((color) => { + const raw = computed.getPropertyValue(color.cssVar).trim() + // HSL format: "210 40% 98%" or similar + const parts = raw.split(/\s+/).map((p) => parseFloat(p)) + let hex = '#000000' + let rgb = '0, 0, 0' + + if (parts.length >= 3) { + hex = hslToHex(parts[0], parts[1], parts[2]) + // Convert hex to RGB + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + rgb = `${r}, ${g}, ${b}` + } + + return { + name: color.name, + hex, + rgb, + usage: color.usage, + } + }) + + setColors(resolved) + } + + // Initial update + updateColors() + + // Watch for theme changes via class or attribute changes on html element + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.attributeName === 'class' || + mutation.attributeName === 'style' || + mutation.attributeName === 'data-theme' + ) { + // Small delay to let CSS variables update + setTimeout(updateColors, 50) + break + } + } + }) + + observer.observe(document.documentElement, { attributes: true }) + + return () => observer.disconnect() + }, []) + + if (colors.length === 0) { + return ( +
+ {THEME_COLORS.map((color) => ( +
+
+
+
+
+
+
+ ))} +
+ ) + } + + return ( +
+ {colors.map((color) => { + const isDark = getLuminance(color.hex) < 0.5 + return ( +
+
+ + {color.hex} + +
+
+
{color.name}
+
+ RGB: {color.rgb} +
+
+ {color.usage} +
+
+
+ ) + })} +
+ ) +} diff --git a/app/(marketing)/press/page.tsx b/app/(marketing)/press/page.tsx new file mode 100644 index 0000000..0661118 --- /dev/null +++ b/app/(marketing)/press/page.tsx @@ -0,0 +1,360 @@ +import Link from 'next/link' +import Image from 'next/image' + +import { + ArrowRight, + BookOpen, + Calendar, + Download, + ExternalLink, + FileText, + Github, + ImageIcon, + Mail, + Newspaper, + Palette, + Quote, + Shield, + Sparkles, +} from 'lucide-react' + +import { Button } from '@/packages/components/ui/button' +import { Badge } from '@/packages/components/ui/badge' +import HomeShell from '@/packages/components/layout/home-shell' + +// Reusable GlassCard component +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { + return ( +
{children}
+ ) +} + +const PRESS_RESOURCES = [ + { + icon: ImageIcon, + title: 'Media Kit', + description: + 'Logos, screenshots, brand colors, and usage guidelines for press and partners.', + href: '/press/media-kit', + label: 'Download assets', + color: 'text-primary', + bg: 'bg-primary/10', + }, + { + icon: FileText, + title: 'Brand Guidelines', + description: + 'How to properly represent Emberly in articles, presentations, and media.', + href: '/press/media-kit#guidelines', + label: 'View guidelines', + color: 'text-accent-foreground', + bg: 'bg-accent/50', + }, + { + icon: Palette, + title: 'Design Assets', + description: 'High-resolution logos, icons, and marketing materials.', + href: 'https://github.com/EmberlyOSS/Emberly/releases/latest', + label: 'Download ZIP', + external: true, + color: 'text-muted-foreground', + bg: 'bg-muted/50', + }, +] + +const QUICK_FACTS = [ + { label: 'Founded', value: '2022' }, + { label: 'Type', value: 'Open Source' }, + { label: 'License', value: 'GPL 3.0' }, + { label: 'Team', value: 'Remote first' }, +] + +const CONTACT_POINTS = [ + { + icon: Mail, + label: 'Press inquiries', + value: 'press@embrly.ca', + href: 'mailto:press@embrly.ca', + }, + { + icon: Github, + label: 'Technical questions', + value: 'GitHub Issues', + href: 'https://github.com/EmberlyOSS/Emberly/issues', + }, +] + +import { buildPageMetadata } from '@/packages/lib/embeds/metadata' + +export const metadata = buildPageMetadata({ + title: 'Press & Media', + description: + 'Press resources, media kit, and contact information for journalists and media partners.', +}) + +export default function PressPage() { + return ( + +
+ {/* Hero Section */} + +
+
+
+ + + Press & Media + +

+ Press + + Resources + +

+

+ Everything you need to write about Emberly. Download our media + kit, access brand assets, or get in touch for interviews and + inquiries. +

+ +
+ + +
+
+ + {/* Quick Facts Card */} +
+
+
+ +
+

+ Quick Facts +

+
+ {QUICK_FACTS.map((fact) => ( +
+
+ {fact.value} +
+
+ {fact.label} +
+
+ ))} +
+
+ +

+ "A lightweight, developer first file sharing platform + focused on privacy, reliability, and simplicity." +

+
+
+
+
+
+
+
+ + + {/* Resources Grid */} +
+ {PRESS_RESOURCES.map((resource) => ( + +
+
+ +
+

{resource.title}

+

+ {resource.description} +

+
+ +
+
+
+ ))} +
+ + {/* About Emberly & Contact */} +
+ +
+

About Emberly

+
+

+ Emberly is an open-source file sharing platform designed for + developers, teams, and communities who value privacy and + simplicity. Built with modern web technologies, it offers a + self hostable alternative to traditional cloud storage + services. +

+

+ Key features include password-protected files, custom domains, + rich embeds, chunked uploads for large files, and a clean, + intuitive dashboard for content management. The platform is + fully open source under the GPL 3.0 license, allowing anyone + to audit, modify, and self host their own instance. +

+

+ Founded in 2022, Emberly is maintained by a remote first team + of developers passionate about privacy respecting software. + The project welcomes contributions from the community. +

+
+
+ + + Open Source + + + + Privacy First + + + + Since 2022 + +
+
+
+ + +
+

Press Contact

+

+ For interviews, media inquiries, or partnership opportunities. +

+
+ {CONTACT_POINTS.map((contact) => ( + +
+ +
+
+
+ {contact.label} +
+
+ {contact.value} +
+
+ +
+ ))} +
+
+

+ Response time: Usually within 1-2 business days +

+
+
+
+
+ + {/* Logo Preview */} + +
+
+
+

Logo & Brand

+

+ Download our logo in various formats. Always use the official + assets when representing Emberly. +

+
+
+
+ Emberly Logo +
+
+ Emberly Logo Alt +
+
+
+
+ + +
+
+
+
+ + ) +} diff --git a/app/(marketing)/pricing/layout.tsx b/app/(marketing)/pricing/layout.tsx new file mode 100644 index 0000000..3ad31bf --- /dev/null +++ b/app/(marketing)/pricing/layout.tsx @@ -0,0 +1,7 @@ +export default function PricingLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/app/(main)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx similarity index 100% rename from app/(main)/pricing/page.tsx rename to app/(marketing)/pricing/page.tsx diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 36ac54f..3cad53d 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,6 +1,170 @@ +import pkg from '@/package.json' + import { apiResponse } from '@/packages/lib/api/response' +import { getRedisClient } from '@/packages/lib/cache/redis' +import { getConfig } from '@/packages/lib/config' +import { + isCloudEnabled, + isCloudConfigMismatched, +} from '@/packages/lib/config/env' +import { prisma } from '@/packages/lib/database/prisma' +import { eventQueue } from '@/packages/lib/events/bullmq/queue' +import { isWorkerRunning } from '@/packages/lib/events/init' +import { loggers } from '@/packages/lib/logger' +import { getStorageProvider } from '@/packages/lib/storage' +import { isStripeConfigured } from '@/packages/lib/stripe/client' -export async function GET() { - return apiResponse({ status: 'ok' }) +const logger = loggers.api +const startedAt = Date.now() + +// The storage check performs a real write+delete against production storage +// (a live S3 API call, or a real disk write) — cache it briefly so frequent +// polling (uptime monitors, Docker HEALTHCHECK) doesn't churn storage on +// every single request. +const STORAGE_CHECK_CACHE_MS = 30_000 +let cachedStorageCheck: { ok: boolean; latencyMs: number; at: number } | null = + null + +async function timed( + fn: () => Promise +): Promise<{ ok: boolean; latencyMs: number; value?: T }> { + const start = Date.now() + try { + const value = await fn() + return { ok: true, latencyMs: Date.now() - start, value } + } catch (error) { + logger.warn('Health check probe failed', { + error: (error as Error).message, + }) + return { ok: false, latencyMs: Date.now() - start } + } +} + +async function checkStorageRoundTrip(): Promise<{ + ok: boolean + latencyMs: number + at: number +}> { + if ( + cachedStorageCheck && + Date.now() - cachedStorageCheck.at < STORAGE_CHECK_CACHE_MS + ) { + return cachedStorageCheck + } + + const result = await timed(async () => { + const provider = await getStorageProvider() + const testPath = `uploads/.health-check-${Date.now()}-${process.pid}.tmp` + await provider.uploadFile( + Buffer.from('emberly-health-check'), + testPath, + 'text/plain' + ) + await provider.deleteFile(testPath) + }) + + cachedStorageCheck = { + ok: result.ok, + latencyMs: result.latencyMs, + at: Date.now(), + } + return cachedStorageCheck } +export async function GET() { + const cloud = isCloudEnabled() + const workerRunning = isWorkerRunning() + + const [ + dbCheck, + redisCheck, + storageCheck, + queueCheck, + stripeConfigured, + config, + ] = await Promise.all([ + timed(() => prisma.$queryRaw`SELECT 1`), + timed(async () => { + const client = await getRedisClient() + await client.ping() + }), + // Real round-trip (write + delete through the exact provider/path + // uploads use), cached briefly — see checkStorageRoundTrip above. + checkStorageRoundTrip(), + timed(() => + eventQueue.getJobCounts('waiting', 'active', 'failed', 'delayed') + ), + cloud ? isStripeConfigured() : Promise.resolve(null), + getConfig(), + ]) + + const checks: Record = { + database: { + status: dbCheck.ok ? 'up' : 'down', + latencyMs: dbCheck.latencyMs, + }, + redis: { + status: redisCheck.ok ? 'up' : 'down', + latencyMs: redisCheck.latencyMs, + }, + storage: { + status: storageCheck.ok ? 'up' : 'down', + provider: config.settings.general.storage.provider, + latencyMs: storageCheck.latencyMs, + checkedAt: new Date(storageCheck.at).toISOString(), + }, + virusScanning: { + status: process.env.VIRUSTOTAL_API_KEY ? 'configured' : 'not_configured', + }, + eventQueue: { + status: !workerRunning ? 'disabled' : queueCheck.ok ? 'up' : 'down', + workerRunning, + latencyMs: queueCheck.latencyMs, + ...(queueCheck.value ?? {}), + }, + } + + if (cloud) { + checks.stripe = { + status: stripeConfigured ? 'configured' : 'not_configured', + } + } + + const cloudConfigMismatched = isCloudConfigMismatched() + + const warnings: string[] = [] + if (cloudConfigMismatched) { + warnings.push( + 'EMBERLY_RUN_CLOUD and NEXT_PUBLIC_EMBERLY_RUN_CLOUD are set to different values — server-side routing and client-rendered nav/UI will disagree about cloud mode. Set both to the same value and restart.' + ) + } + + const isDown = !dbCheck.ok + const isDegraded = + !redisCheck.ok || + !storageCheck.ok || + (workerRunning && !queueCheck.ok) || + cloudConfigMismatched + + const status = isDown ? 'down' : isDegraded ? 'degraded' : 'ok' + + if (status !== 'ok') { + logger.warn('Health check reported non-ok status', { + status, + checks, + warnings, + }) + } + + return apiResponse( + { + status, + version: pkg.version, + uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000), + cloud, + warnings, + checks, + }, + status === 'down' ? 503 : 200 + ) +} diff --git a/app/api/updates/check/route.ts b/app/api/updates/check/route.ts index 041d9ad..fd41c76 100644 --- a/app/api/updates/check/route.ts +++ b/app/api/updates/check/route.ts @@ -25,7 +25,7 @@ export async function GET() { if (response) return response const currentVersion = pkg.version - const releases = await getRepoReleases('EmberlyOSS', 'Website', 30) + const releases = await getRepoReleases('EmberlyOSS', 'Emberly', 30) const stableReleases = releases .filter((release) => !release.prerelease && !release.draft) @@ -61,4 +61,3 @@ export async function GET() { ) } } - diff --git a/app/api/users/[id]/contributions/route.ts b/app/api/users/[id]/contributions/route.ts index 9dcd9ee..3785f57 100644 --- a/app/api/users/[id]/contributions/route.ts +++ b/app/api/users/[id]/contributions/route.ts @@ -1,4 +1,6 @@ import { NextResponse } from 'next/server' +import { apiError, HTTP_STATUS } from '@/packages/lib/api/response' +import { isCloudEnabled } from '@/packages/lib/config/env' import { prisma } from '@/packages/lib/database/prisma' import { getCommitDetail, @@ -11,6 +13,10 @@ export async function GET( req: Request, { params }: { params: Promise<{ id: string }> } ) { + if (!isCloudEnabled()) { + return apiError('Not found', HTTP_STATUS.NOT_FOUND) + } + try { const { id } = await params diff --git a/app/error.tsx b/app/error.tsx index f57bb85..5945313 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -20,13 +20,18 @@ import { DynamicBackground } from '@/packages/components/layout/dynamic-backgrou import { BaseNav } from '@/packages/components/layout/base-nav' import { Button } from '@/packages/components/ui/button' import { Badge } from '@/packages/components/ui/badge' +import { DISCORD_INVITE_URL } from '@/packages/lib/constants/site' // Reusable GlassCard component -function GlassCard({ children, className = '' }: { children: React.ReactNode; className?: string }) { +function GlassCard({ + children, + className = '', +}: { + children: React.ReactNode + className?: string +}) { return ( -
- {children} -
+
{children}
) } @@ -55,7 +60,10 @@ export default function Error({ url: typeof window !== 'undefined' ? window.location.href : 'unknown', stack: error.stack?.slice(0, 2000), type: 'client', - userAgent: typeof window !== 'undefined' ? window.navigator.userAgent : undefined, + userAgent: + typeof window !== 'undefined' + ? window.navigator.userAgent + : undefined, }), }).catch(() => {}) // Never throw from error handler }, [error]) // Only run when error changes @@ -66,7 +74,7 @@ export default function Error({ await navigator.clipboard.writeText(errorInfo) setCopied(true) setTimeout(() => setCopied(false), 2000) - } catch { } + } catch {} } return ( @@ -104,7 +112,10 @@ export default function Error({ Error {error?.digest && ( - + Code: {error.digest} )} @@ -129,7 +140,12 @@ export default function Error({ Try Again -
- - - -
+ {/* Description */} +

+ The page you're looking for doesn't exist or has been moved. + Don't worry - let's get you back on track. +

- {/* Fun Message */} -
-
- -
-

While you're here...

-

- Why not play a quick game? Click "Play a Game" and try to beat your high score! -

-
-
-
-
- + {/* Actions */} +
+ + +
- {/* Suggested Pages */} -
-
- -

Looking for something?

-
+ {/* Fun Message */} +
+
+ +
+

+ While you're here... +

+

+ Why not play a quick game? Click "Play a Game" and try + to beat your high score! +

+
+
+
+
+ -
- {SUGGESTED_PAGES.map((page) => ( - - -
-
- -
-
-
- {page.title} -
-
- {page.description} -
-
- -
- -
- ))} -
+ {/* Suggested Pages */} +
+
+ +

+ Looking for something? +

+
- {/* Additional Help */} - -
-

- Still can't find what you need?{' '} - - Contact us - - {' '}and we'll help you out. -

-
-
+
+ {suggestedPages.map((page) => ( + + +
+
+
-
+
+
+ {page.title} +
+
+ {page.description} +
+
+ +
+ + + ))} +
+ + {/* Additional Help */} + +
+

+ Still can't find what you need?{' '} + + Contact us + {' '} + and we'll help you out. +

- +
+
+
- ) + +
+ ) } diff --git a/packages/components/admin/admin-sidebar.tsx b/packages/components/admin/admin-sidebar.tsx index de3f779..b8aad02 100644 --- a/packages/components/admin/admin-sidebar.tsx +++ b/packages/components/admin/admin-sidebar.tsx @@ -19,30 +19,70 @@ import { Users, } from 'lucide-react' +import { isCloudEnabledClient } from '@/packages/lib/config/env' import { ScrollIndicator } from '@/packages/components/ui/scroll-indicator' const adminRoutes = [ { href: '/admin', label: 'Overview', icon: Shield }, - { href: '/admin/blog', label: 'Blogs', icon: BookOpen }, - { href: '/admin/legal', label: 'Legal', icon: Gavel }, + { href: '/admin/blog', label: 'Blogs', icon: BookOpen, cloudOnly: true }, + { href: '/admin/legal', label: 'Legal', icon: Gavel, cloudOnly: true }, { href: '/admin/users', label: 'Users', icon: Users }, { href: '/admin/reports', label: 'Reports', icon: ShieldAlert }, - { href: '/admin/applications', label: 'Applications', icon: ClipboardList }, - { href: '/admin/products', label: 'Products', icon: CreditCard }, - { href: '/admin/email', label: 'Email', icon: Mail, superAdminOnly: true }, - { href: '/admin/partners', label: 'Partners', icon: Handshake }, - { href: '/admin/testimonials', label: 'Testimonials', icon: MessageSquare }, - { href: '/admin/logs', label: 'Audit Logs', icon: ChartBar, superAdminOnly: true }, - { href: '/admin/settings', label: 'Settings', icon: Settings, superAdminOnly: true }, + { + href: '/admin/applications', + label: 'Applications', + icon: ClipboardList, + cloudOnly: true, + }, + { + href: '/admin/products', + label: 'Products', + icon: CreditCard, + cloudOnly: true, + }, + { + href: '/admin/email', + label: 'Email', + icon: Mail, + superAdminOnly: true, + cloudOnly: true, + }, + { + href: '/admin/partners', + label: 'Partners', + icon: Handshake, + cloudOnly: true, + }, + { + href: '/admin/testimonials', + label: 'Testimonials', + icon: MessageSquare, + cloudOnly: true, + }, + { + href: '/admin/logs', + label: 'Audit Logs', + icon: ChartBar, + superAdminOnly: true, + }, + { + href: '/admin/settings', + label: 'Settings', + icon: Settings, + superAdminOnly: true, + }, ] export function AdminSidebar() { const pathname = usePathname() const { data: session } = useSession() const isSuperAdmin = session?.user?.role === 'SUPERADMIN' + const cloudEnabled = isCloudEnabledClient() const visibleRoutes = adminRoutes.filter( - (r) => !('superAdminOnly' in r && r.superAdminOnly) || isSuperAdmin + (r) => + (!('superAdminOnly' in r && r.superAdminOnly) || isSuperAdmin) && + (!('cloudOnly' in r && r.cloudOnly) || cloudEnabled) ) function isActive(href: string) { diff --git a/packages/components/admin/settings/settings-manager.tsx b/packages/components/admin/settings/settings-manager.tsx index ba54508..02a7f17 100644 --- a/packages/components/admin/settings/settings-manager.tsx +++ b/packages/components/admin/settings/settings-manager.tsx @@ -72,6 +72,7 @@ import { } from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { cn } from '@/lib/utils' @@ -83,6 +84,7 @@ import { OVHCloudPoolManager } from '@/packages/components/admin/settings/ovhclo import { StorageBucketManager } from '@/packages/components/admin/settings/storage-bucket-manager' import { UserBucketAssignment } from '@/packages/components/admin/settings/user-bucket-assignment' import { VultrInstanceManager } from '@/packages/components/admin/settings/vultr-instance-manager' +import { isCloudEnabledClient } from '@/packages/lib/config/env' import { useToast } from '@/hooks/use-toast' import { ToastAction } from '@/components/ui/toast' @@ -507,6 +509,7 @@ export function SettingsManager() { const { toast } = useToast() const { data: session } = useSession() const isSuperAdmin = session?.user?.role === 'SUPERADMIN' + const cloudEnabled = isCloudEnabledClient() const [savedConfig, setSavedConfig] = useState(null) const [workingConfig, setWorkingConfig] = useState(null) @@ -1183,7 +1186,7 @@ export function SettingsManager() {
+ {/* Branding */} + +
+
+ + {isFieldChanged('general', ['siteName']) && } +
+ + handleSettingChange('general', { + siteName: e.target.value, + }) + } + placeholder="Emberly" + className={cn( + 'max-w-sm', + getFieldClasses('general', ['siteName']) + )} + /> +

+ Shown in the browser tab, page titles, and social share cards +

+
+ +
+
+ + {isFieldChanged('general', ['metaDescription']) && ( + + )} +
+