diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index f2d3afe..5ba4c6f 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -4,7 +4,6 @@ generator client { datasource db { provider = "postgresql" url = env("DATABASE_URL") - } model User { diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af..1e71cb6 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -495,6 +495,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: attendeeRows, + _count: { attendees: attendeeRows.length }, }); const res = await app.inject({ @@ -523,6 +524,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)], + _count: { attendees: 1 }, }); const res = await app.inject({ @@ -545,6 +547,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [], + _count: { attendees: 0 }, }); const res = await app.inject({ @@ -561,6 +564,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [], + _count: { attendees: 0 }, }); const res = await app.inject({ @@ -577,6 +581,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [], + _count: { attendees: 0 }, }); const res = await app.inject({ @@ -594,6 +599,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], + _count: { attendees: 1 }, }); const res = await app.inject({ diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 94767c5..d9c2391 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -35,8 +35,21 @@ export async function buildApp() { }); // ─── Core Plugins ─── + const allowedOrigins: string[] = []; + if (process.env.NODE_ENV !== 'production') { + allowedOrigins.push( + 'http://localhost:5173', + 'http://localhost:5174', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:5174' + ); + } + if (process.env.PUBLIC_APP_URL) { + allowedOrigins.push(process.env.PUBLIC_APP_URL); + } + await app.register(cors, { - origin: process.env.PUBLIC_APP_URL || 'http://localhost:5173', + origin: allowedOrigins, credentials: true, }); diff --git a/apps/backend/src/plugins/redis.ts b/apps/backend/src/plugins/redis.ts index c7b6f94..976fb42 100644 --- a/apps/backend/src/plugins/redis.ts +++ b/apps/backend/src/plugins/redis.ts @@ -14,6 +14,10 @@ export const redisPlugin = fp(async (app: FastifyInstance) => { lazyConnect: true, }); + redis.on('error', (err) => { + app.log.debug(`Redis connection state: offline (${err.message})`); + }); + try { await redis.connect(); app.log.info('🔴 Redis connected'); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index fc90f2e..810ddb5 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { randomBytes } from 'crypto'; import { encrypt } from '../utils/encryption.js'; +import { bypassAuthSchema } from '../utils/validators.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -345,6 +346,56 @@ app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthC }; }); + // ─── Local Developer Bypass Auth ─── + + app.post('/bypass', async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = bypassAuthSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); + } + + const { username } = parsed.data; + const cleanUsername = username.toLowerCase(); + + try { + const user = await app.prisma.user.upsert({ + where: { + username: cleanUsername, + }, + update: {}, + create: { + email: `${cleanUsername}@devcard.local`, + username: cleanUsername, + displayName: username, + bio: 'Full-stack developer building outstanding interfaces.', + role: 'Software Engineer', + company: 'DevCard Team', + avatarUrl: `https://api.dicebear.com/7.x/bottts/svg?seed=${cleanUsername}`, + provider: 'dev_bypass', + providerId: `bypass_${cleanUsername}`, + }, + }); + + const token = app.jwt.sign( + { id: user.id, username: user.username }, + { expiresIn: '30d' } + ); + + reply.setCookie('token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 30 * 24 * 60 * 60, + }); + + return { user }; + } catch (err) { + app.log.error({ err }, 'Bypass login error'); + return reply.status(500).send({ error: 'Authentication bypass failed' }); + } + }); + // ─── Logout ─── app.post('/logout', async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index 80d2caa..de107cc 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -42,3 +42,12 @@ export const updateCardSchema = z.object({ title: z.string().min(1).max(100).optional(), linkIds: z.array(z.string().uuid()).optional(), }); + +export const bypassAuthSchema = z.object({ + username: z + .string() + .min(1, 'Username is required') + .max(50, 'Username must not exceed 50 characters') + .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, hyphens, and underscores'), +}); + diff --git a/apps/web/src/app.css b/apps/web/src/app.css index c65380f..c4759b6 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -19,6 +19,11 @@ --text-secondary: #475569; --text-muted: #64748b; + /* Secondary Button & Inputs */ + --btn-secondary-bg: rgba(15, 23, 42, 0.05); + --btn-secondary-border: rgba(15, 23, 42, 0.08); + --btn-secondary-hover-bg: rgba(15, 23, 42, 0.09); + /* Effects */ --border: rgba(226, 232, 240, 0.9); --border-glass: rgba(255, 255, 255, 0.35); @@ -44,6 +49,10 @@ html.dark { --text-secondary: #cbd5e1; --text-muted: #64748b; + --btn-secondary-bg: rgba(255, 255, 255, 0.08); + --btn-secondary-border: rgba(255, 255, 255, 0.14); + --btn-secondary-hover-bg: rgba(255, 255, 255, 0.14); + --border: rgba(30, 41, 59, 0.85); --border-glass: rgba(255, 255, 255, 0.12); } @@ -131,14 +140,14 @@ button { padding: 0.85rem 1.75rem; border-radius: calc(var(--radius) * 1.2); font-weight: 700; - border: 1px solid rgba(255, 255, 255, 0.14); - background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--btn-secondary-border); + background: var(--btn-secondary-bg); color: var(--text-primary); cursor: pointer; } .btn-secondary:hover { - background: rgba(255, 255, 255, 0.14); + background: var(--btn-secondary-hover-bg); border-color: rgba(99, 102, 241, 0.45); } diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 666257e..0f913c7 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -1,16 +1,26 @@ - - - - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - + + + + + + + + + + + %sveltekit.head% + + + +
%sveltekit.body%
+ + + \ No newline at end of file diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..c861162 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,30 @@ +export async function apiFetch(path: string, options: RequestInit = {}) { + // Route all browser fetches through the SvelteKit /api/ proxy gateway + const cleanPath = path.startsWith('/') ? path.substring(1) : path; + const url = `/api/${cleanPath}`; + + const headers = new Headers(options.headers); + if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + let errorMsg = 'An error occurred'; + try { + const data = await response.json(); + errorMsg = data.error || errorMsg; + } catch {} + throw new Error(errorMsg); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index ac6dd12..ea7edec 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,6 +1,16 @@ diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 3d9dd69..b4abca2 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -41,14 +41,17 @@ @@ -59,15 +62,10 @@ The open-source standard for developer networking. Put all your profiles—GitHub, LinkedIn, LeetCode, and more—into a single, high-impact digital card.

- - ⭐ Star on GitHub + + Create Your DevCard ⚡ - View Demo Profile → + View Demo Profile →
@@ -137,8 +135,8 @@ .theme-toggle { width: 46px; height: 46px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--btn-secondary-bg); + border: 1px solid var(--btn-secondary-border); border-radius: 50%; cursor: pointer; display: inline-flex; @@ -150,7 +148,7 @@ .theme-toggle:hover { transform: scale(1.05); - background: rgba(255, 255, 255, 0.14); + background: var(--btn-secondary-hover-bg); } .theme-toggle:focus-visible { @@ -210,16 +208,21 @@ padding: 0.92rem 1.75rem; border-radius: calc(var(--radius) * 1.15); font-weight: 700; - border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--btn-secondary-border); + background: var(--btn-secondary-bg); color: var(--text-primary); } .btn-secondary:hover { - background: rgba(255, 255, 255, 0.14); + background: var(--btn-secondary-hover-bg); border-color: rgba(99, 102, 241, 0.45); } + .btn-secondary:focus-visible { + outline: 3px solid rgba(99, 102, 241, 0.18); + outline-offset: 3px; + } + .features { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); @@ -227,6 +230,18 @@ padding: 4rem 0 5rem; } + .feature-card { + padding: 2rem; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); + background: var(--bg-glass); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid var(--border-glass); + transition: transform 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease; + min-height: 180px; + display: flex; + flex-direction: column; .feature-card { padding: 2.4rem; min-height: 140px; @@ -354,6 +369,7 @@ .footer { padding: 2rem 0 1.25rem; } + .bg-glow { opacity: 0.6; diff --git a/apps/web/src/routes/api/[...path]/+server.ts b/apps/web/src/routes/api/[...path]/+server.ts new file mode 100644 index 0000000..c926bb7 --- /dev/null +++ b/apps/web/src/routes/api/[...path]/+server.ts @@ -0,0 +1,68 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const API_BASE = process.env.BACKEND_URL || 'http://localhost:3000'; + +export const fallback: RequestHandler = async ({ params, request, cookies, url }) => { + const path = params.path; + const token = cookies.get('token'); + + // Build headers + const headers = new Headers(request.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + // Suppress headers that could conflict during local proxying + headers.delete('host'); + headers.delete('connection'); + + // Fastify route resolution: strip any duplicate leading api/ prefixes + let cleanPath = path; + if (cleanPath.startsWith('api/')) { + cleanPath = cleanPath.substring(4); + } + const targetPath = cleanPath.startsWith('auth/') ? cleanPath : `api/${cleanPath}`; + const targetUrl = new URL(`${API_BASE}/${targetPath}${url.search}`); + + try { + let requestBody: any = undefined; + if (request.method !== 'GET' && request.method !== 'HEAD') { + const blob = await request.blob(); + if (blob.size > 0) { + requestBody = blob; + } else { + headers.delete('content-type'); + } + } + + const res = await fetch(targetUrl.toString(), { + method: request.method, + headers, + body: requestBody, + }); + + const responseHeaders = new Headers(); + const contentType = res.headers.get('Content-Type'); + if (contentType) { + responseHeaders.set('Content-Type', contentType); + } + + // Forward cookies set by the backend (like token set on login) + const setCookie = res.headers.get('Set-Cookie'); + if (setCookie) { + responseHeaders.set('Set-Cookie', setCookie); + } + + return new Response(res.body, { + status: res.status, + headers: responseHeaders, + }); + } catch (err: any) { + console.error('DevCard Proxy Error:', err); + return new Response(JSON.stringify({ error: 'Backend API is currently offline.' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } +}; diff --git a/apps/web/src/routes/dashboard/+page.server.ts b/apps/web/src/routes/dashboard/+page.server.ts new file mode 100644 index 0000000..40e5d90 --- /dev/null +++ b/apps/web/src/routes/dashboard/+page.server.ts @@ -0,0 +1,38 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const API_BASE = process.env.BACKEND_URL || 'http://localhost:3000'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const token = cookies.get('token'); + if (!token) { + throw redirect(302, '/login'); + } + + try { + const res = await fetch(`${API_BASE}/api/profiles/me`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + // Token is invalid or expired, clear and redirect + cookies.delete('token', { path: '/' }); + throw redirect(302, '/login'); + } + + const user = await res.json(); + return { user }; + } catch (err: any) { + // If the error is a SvelteKit redirect instruction, forward it + if (err.status && err.status >= 300 && err.status < 400) { + throw err; + } + + return { + user: null, + error: 'Backend API is currently unreachable. Please make sure the backend server is running.', + }; + } +}; diff --git a/apps/web/src/routes/dashboard/+page.svelte b/apps/web/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..87a6db0 --- /dev/null +++ b/apps/web/src/routes/dashboard/+page.svelte @@ -0,0 +1,1196 @@ + + +
+ +
+ {#each toasts as toast (toast.id)} +
+ {toast.type === 'success' ? '⚡' : '⚠️'} + {toast.message} +
+ {/each} +
+ +
+ + + + +
+ + +
+
+ 🎨 +

Card Customizer

+
+

Fine-tune your brand colors and professional details.

+ +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ + + + + +
+
+ 📱 +

Dynamic Card Live View

+
+

Real-time preview of your public identity page.

+ + +
+
+
+ +
+ {#if user.avatarUrl} + {user.displayName} + {:else} +
+ {user.displayName.charAt(0).toUpperCase()} +
+ {/if} +

{user.displayName}

+ {#if user.role} + {user.role}{user.company ? ` @ ${user.company}` : ''} + {/if} + {#if user.bio} +

{user.bio}

+ {/if} +
+ + +
+
+ + + +
+ +
+
+ + +{#if isModalOpen} + +{/if} + + diff --git a/apps/web/src/routes/login/+page.svelte b/apps/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..6b63775 --- /dev/null +++ b/apps/web/src/routes/login/+page.svelte @@ -0,0 +1,335 @@ + + + + Login | DevCard Studio ⚡ + + +
+ +
+ +
+ + diff --git a/apps/web/src/routes/u/[username]/+page.svelte b/apps/web/src/routes/u/[username]/+page.svelte index 50cb422..2f85ccd 100644 --- a/apps/web/src/routes/u/[username]/+page.svelte +++ b/apps/web/src/routes/u/[username]/+page.svelte @@ -158,7 +158,7 @@ right: 0; bottom: 0; background: radial-gradient(circle at 50% 0%, var(--accent), transparent 50%), - #020617; + var(--bg-page); opacity: 0.18; z-index: -1; } @@ -184,11 +184,13 @@ max-width: 540px; border-radius: var(--radius-xl); padding: 2.5rem 2rem; - box-shadow: 0 26px 60px -20px rgba(0, 0, 0, 0.55); + box-shadow: var(--shadow-lg); position: relative; overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(15, 23, 42, 0.96); + border: 1px solid var(--border-glass); + background: var(--bg-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); } .profile-header { @@ -237,8 +239,8 @@ align-items: center; justify-content: center; padding: 0.45rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--btn-secondary-bg); + border: 1px solid var(--btn-secondary-border); border-radius: 999px; font-size: 0.9rem; font-weight: 700; @@ -265,9 +267,9 @@ align-items: center; padding: 1rem; border-radius: calc(var(--radius) * 1.1); - border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.06); - box-shadow: 0 12px 30px -18px rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + background: var(--bg-secondary); + box-shadow: var(--shadow-sm); transition: transform 0.25s ease, background 0.25s ease, border-color 0.25s ease; animation: slideIn 0.5s ease-out forwards; animation-delay: var(--delay); @@ -276,9 +278,10 @@ .link-tile:hover, .link-tile:focus-visible { - background: rgba(255, 255, 255, 0.13); + background: var(--bg-primary); transform: translateY(-2px); - border-color: rgba(99, 102, 241, 0.35); + border-color: var(--primary); + box-shadow: var(--shadow-md); } .link-tile:focus-visible { @@ -336,7 +339,7 @@ .card-footer { margin-top: 2.5rem; padding-top: 1.75rem; - border-top: 1px solid rgba(255,255,255,0.08); + border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; @@ -377,9 +380,9 @@ } .copy-link-button { - border: 1px solid var(--border-glass); + border: 1px solid var(--btn-secondary-border); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.08); + background: var(--btn-secondary-bg); color: var(--text-primary); cursor: pointer; font: inherit; @@ -389,7 +392,7 @@ } .copy-link-button:hover { - background: rgba(255, 255, 255, 0.15); + background: var(--btn-secondary-hover-bg); transform: translateY(-1px); } diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index 55c3bd2..38b2fca 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -14,7 +14,7 @@ const config = { 'script-src': ['self', 'unsafe-inline'], 'style-src': ['self', 'unsafe-inline', 'https://fonts.googleapis.com'], 'img-src': ['self', 'data:', 'https:'], - 'connect-src': ['self'], + 'connect-src': ['self', 'http://localhost:3000', 'ws://localhost:5173'], 'font-src': ['self', 'data:', 'https:', 'https://fonts.gstatic.com'], 'object-src': ['none'], 'base-uri': ['self'], diff --git a/package.json b/package.json index bbe44f7..77d8e64 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "One Tap. Every Profile. Every Platform. Open Source Developer Profile Exchange Platform.", "license": "Apache-2.0", "scripts": { + "dev": "concurrently \"pnpm dev:backend\" \"pnpm dev:web\"", "dev:backend": "pnpm --filter @devcard/backend dev", "dev:mobile": "pnpm --filter @devcard/mobile start", "dev:web": "pnpm --filter @devcard/web dev", diff --git a/packages/shared/src/platforms.ts b/packages/shared/src/platforms.ts index a218957..c0de90f 100644 --- a/packages/shared/src/platforms.ts +++ b/packages/shared/src/platforms.ts @@ -162,6 +162,32 @@ export const PLATFORMS: Record = { usernamePlaceholder: 'e.g. coder', usesFullUrl: false, }, + geeksforgeeks: { + id: 'geeksforgeeks', + name: 'GeeksforGeeks', + icon: 'code', + color: '#2F8D46', + urlPattern: 'https://www.geeksforgeeks.org/user/{username}', + deepLinkPattern: null, + webViewUrlPattern: null, + followStrategy: 'link', + oauthScopes: [], + usernamePlaceholder: 'e.g. gfg_user', + usesFullUrl: false, + }, + codeforces: { + id: 'codeforces', + name: 'Codeforces', + icon: 'award', + color: '#1A8FFF', + urlPattern: 'https://codeforces.com/profile/{username}', + deepLinkPattern: null, + webViewUrlPattern: null, + followStrategy: 'link', + oauthScopes: [], + usernamePlaceholder: 'e.g. tourist', + usesFullUrl: false, + }, hackerrank: { id: 'hackerrank', name: 'HackerRank',