@@ -516,7 +518,9 @@ function AppGridCard({
'bg-white border border-gray-100 hover:border-string-mint hover:shadow-sm',
'bg-[#2a2d30] border border-[#3a3f44] hover:border-string-mint'
)} ${swipeProps.isSwipeMenuOpen ? 'transform -translate-x-20' : ''}`}
- {...swipeProps}
+ onTouchStart={swipeProps.handlers.onTouchStart}
+ onTouchMove={swipeProps.handlers.onTouchMove}
+ onTouchEnd={swipeProps.handlers.onTouchEnd}
>
{app.logoUrl ? (
diff --git a/src/hooks/useSwipe.ts b/src/hooks/useSwipe.ts
index 39933fb..235a504 100644
--- a/src/hooks/useSwipe.ts
+++ b/src/hooks/useSwipe.ts
@@ -63,11 +63,15 @@ export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 100 }: UseSwip
setIsSwipeMenuOpen(false);
}, []);
- return {
+ const handlers = {
onTouchStart,
onTouchMove,
onTouchEnd,
onClick,
+ };
+
+ return {
+ handlers,
isSwipeMenuOpen,
closeSwipeMenu,
setIsSwipeMenuOpen
From f514681602baecbf4b642d011df4bef4b294ccfb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 16 Feb 2026 10:28:34 +0000
Subject: [PATCH 3/3] Merge main and resolve conflicts while preserving handler
separation
Co-authored-by: ghostleek <44336310+ghostleek@users.noreply.github.com>
---
README.md | 12 +-
api/profile/add-app.ts | 105 +++++++++++
claude.md | 113 ++++++++++--
package-lock.json | 215 +++++++++++++++++++++++
package.json | 2 +
public/mark_green.svg | 1 +
src/App.tsx | 78 ++++++--
src/components/AppSubmissionForm.tsx | 39 +++-
src/components/DevProfileMock.tsx | 86 +++++++--
src/components/PersonalProfile.tsx | 118 ++++++++++---
src/components/QRCodeModal.tsx | 179 +++++++++++++++++++
src/components/SignInModal.tsx | 4 +-
src/components/ToastContainer.tsx | 41 +++++
src/components/profile/AppsList.tsx | 33 +++-
src/components/profile/ProfileFooter.tsx | 4 +-
src/components/profile/ProfileHeader.tsx | 83 +++++++--
src/hooks/useSwipe.ts | 29 ++-
src/hooks/useToast.ts | 36 ++++
src/lib/auth-client.ts | 1 +
19 files changed, 1087 insertions(+), 92 deletions(-)
create mode 100644 api/profile/add-app.ts
create mode 100644 public/mark_green.svg
create mode 100644 src/components/QRCodeModal.tsx
create mode 100644 src/components/ToastContainer.tsx
create mode 100644 src/hooks/useToast.ts
diff --git a/README.md b/README.md
index 4814554..7e3074c 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,23 @@
A consolidated app launcher for educators that surfaces relevant apps at point-of-need.
+## Features
+
+- **Curated App Directory**: 42+ education apps used across Singapore MOE schools
+- **Personal Profiles**: Public profile pages showing your pinned and submitted apps
+- **Smart Submission**: Autocomplete prevents duplicate submissions
+- **Profile App Management**: Add apps to your profile directly from existing library
+- **Google OAuth**: Secure authentication with Google accounts
+- **Mobile-Optimized**: Responsive design with touch-friendly interactions
+
## Tech Stack
- **Frontend:** React 19 + Vite 7 + TypeScript
-- **Styling:** Tailwind CSS 4
+- **Styling:** Tailwind CSS 4 + String Design System
- **Database:** NeonDB (PostgreSQL)
- **ORM:** Drizzle
- **API:** Vercel Edge Functions
+- **Auth:** Google OAuth (client-side)
## Testing Locally
diff --git a/api/profile/add-app.ts b/api/profile/add-app.ts
new file mode 100644
index 0000000..c08d911
--- /dev/null
+++ b/api/profile/add-app.ts
@@ -0,0 +1,105 @@
+import { neon } from '@neondatabase/serverless';
+import { drizzle } from 'drizzle-orm/neon-http';
+import { apps, userProfileApps } from '../../src/db/schema';
+import { eq, and } from 'drizzle-orm';
+
+export const config = {
+ runtime: 'edge',
+};
+
+const connectionString = process.env.DATABASE_URL;
+if (!connectionString) {
+ throw new Error('DATABASE_URL environment variable is not set');
+}
+
+export default async function handler(request: Request) {
+ const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+ };
+
+ if (request.method === 'OPTIONS') {
+ return new Response(null, {
+ status: 200,
+ headers: corsHeaders,
+ });
+ }
+
+ if (request.method !== 'POST') {
+ return new Response(JSON.stringify({ error: 'Method not allowed' }), {
+ status: 405,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ try {
+ const sqlClient = neon(connectionString!);
+ const db = drizzle(sqlClient);
+
+ const body = await request.json();
+ const { appId, userId } = body;
+
+ if (!appId || !userId) {
+ return new Response(JSON.stringify({ error: 'App ID and User ID are required' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Check if app exists in main library
+ const appExists = await db
+ .select()
+ .from(apps)
+ .where(eq(apps.id, appId))
+ .limit(1);
+
+ if (appExists.length === 0) {
+ return new Response(JSON.stringify({ error: 'App not found' }), {
+ status: 404,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Check if already in user's profile
+ const existing = await db
+ .select()
+ .from(userProfileApps)
+ .where(and(
+ eq(userProfileApps.userId, userId),
+ eq(userProfileApps.appId, appId)
+ ))
+ .limit(1);
+
+ if (existing.length > 0) {
+ return new Response(JSON.stringify({ message: 'App already in profile', existing: true }), {
+ status: 200,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Add to user's profile
+ await db.insert(userProfileApps).values({
+ userId,
+ appId,
+ appType: 'pinned',
+ isVisible: true,
+ displayOrder: 0
+ });
+
+ return new Response(JSON.stringify({
+ success: true,
+ message: 'App added to profile'
+ }), {
+ status: 200,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+
+ } catch (error) {
+ console.error('Error adding app to profile:', error);
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+}
diff --git a/claude.md b/claude.md
index 115171c..2b11edd 100644
--- a/claude.md
+++ b/claude.md
@@ -1,7 +1,7 @@
# String.sg v2 - Development Plan
-**Last Updated:** 2026-02-12
-**Status:** Phase 4 Complete + UI Design System Established
+**Last Updated:** 2026-02-13
+**Status:** Phase 5 In Progress - Profile App Management
---
@@ -55,12 +55,21 @@
- Removed duplicate title in submission form
- Submission modal accessible from both homepage and dashboard + buttons
-### 🔲 Phase 5: Personal Profile Pages (NEXT)
-- [ ] Email-prefix slug generation (e.g., `string.sg/lee-kh`)
-- [ ] Public profile API (`/api/users/[slug]`)
-- [ ] Personal launcher page (`/[slug]`) showing pinned + submitted apps
+### 🔄 Phase 5: Personal Profile Pages (IN PROGRESS)
+- [x] Email-prefix slug generation (e.g., `string.sg/lee-kh`) ✅
+- [x] Public profile API (`/api/profile/[slug]`) ✅
+- [x] Personal launcher page (`/[slug]`) showing pinned + submitted apps ✅
+- [x] DevProfileMock for local development without API ✅
+- [x] **Profile App Management** (NEW):
+ - [x] "+ Add App" button shown when viewing own profile (replaces empty state)
+ - [x] Modal with app submission form (with autocomplete)
+ - [x] Autocomplete detects existing apps and prevents duplicates
+ - [x] Smart redirect: clicking existing app redirects back to profile
+ - [x] Auto-pin to homepage + auto-add to profile via query params
+ - [x] `/api/profile/add-app` endpoint to add apps to user_profile_apps
+ - [x] Profile refreshes to show newly added app
- [ ] Inline visibility controls with WYSIWYG public preview toggle
-- [ ] Profile app management (add/remove from public profile)
+- [ ] Hide/show apps from profile (different from unpinning)
### 🔲 Phase 6: PWA Support
- [ ] manifest.json for installability
@@ -104,8 +113,13 @@
string-v2/
├── api/
│ ├── apps.ts # GET /api/apps - Edge function ✅
-│ └── auth/
-│ └── [...nextauth].ts # NextAuth.js configuration ✅
+│ ├── profile/
+│ │ ├── [slug].ts # GET /api/profile/[slug] - Public profile data ✅
+│ │ ├── manage.ts # Profile management API ✅
+│ │ └── add-app.ts # POST /api/profile/add-app - Add app to profile ✅
+│ ├── submissions.ts # POST /api/submissions - Submit new app ✅
+│ ├── users.ts # POST /api/users - Create/update user ✅
+│ └── preferences.ts # User preferences API ✅
├── data/
│ ├── schools.json # 320 MOE schools ✅
│ └── apps-seed.json # 42 apps with metadata ✅
@@ -164,13 +178,22 @@ string-v2/
apps -- 42 apps seeded ✅
bump_rules -- Time/date-based promotion rules ✅
featured_apps -- Daily featured app with messaging
-users -- User accounts (optional auth)
-user_preferences -- App arrangement, hidden/pinned
+users -- User accounts with slug generation ✅
+user_preferences -- App arrangement, hidden/pinned ✅
user_app_launches -- Analytics tracking
-app_submissions -- UGC with moderation workflow
+app_submissions -- UGC with moderation workflow ✅
+user_profile_apps -- Apps visible on public profile (pinned + submitted) ✅
categories -- 8 categories ✅
```
+### Schema Notes
+- **users.id**: TEXT (OAuth provider IDs are strings, not UUIDs)
+- **users.slug**: Generated from email prefix (e.g., lee_kah_how@moe.edu.sg → lee-kah-how)
+- **user_profile_apps**: Links users to apps shown on their public profile
+ - `app_type`: 'pinned' or 'submitted'
+ - `is_visible`: Controls public visibility
+ - `display_order`: Custom ordering (future feature)
+
---
## Research Findings
@@ -269,16 +292,32 @@ CREATE TABLE user_profile_apps (
);
```
-#### API Routes to Create
+#### API Routes
```typescript
-// /api/users/[slug].ts - Get public profile data
-GET /api/users/john-doe
-Response: { user: {...}, apps: [...] }
+// /api/profile/[slug].ts - Get public profile data ✅
+GET /api/profile/john-doe
+Response: { profile: {...}, apps: [...] }
+
+// /api/profile/add-app.ts - Add app to user profile ✅
+POST /api/profile/add-app { appId, userId }
+Response: { success: true, message: 'App added to profile' }
-// /api/profile/apps.ts - Manage profile apps visibility
+// /api/profile/apps.ts - Manage profile apps visibility (FUTURE)
POST /api/profile/apps { appId, type, isVisible }
```
+#### Query Param Flow (Profile App Management) ✅
+```
+User on profile → Selects existing app from autocomplete
+→ Clicks "Add to profile and homepage →"
+→ Redirects to: /[slug]?pin=appId&addToProfile=true
+→ Profile page useEffect detects params:
+ 1. Calls togglePinnedApp(appId) to pin to homepage
+ 2. Calls /api/profile/add-app to add to profile
+ 3. Reloads profile data to show new app
+ 4. Cleans up URL (removes query params)
+```
+
#### Components Architecture ✅ COMPLETE
```typescript
// ProfileHeader.tsx - Profile info display (abstracted) ✅
@@ -330,6 +369,32 @@ mkdir extension
---
+## Recent Improvements (2026-02-13)
+
+### Profile App Management
+**Problem:** Users couldn't add apps to their profile, had to manually submit and wait for approval
+**Solution:**
+1. **Dynamic Empty State** - Profile shows "+ Add App" button when viewing your own empty profile (other users see "No Apps Shared")
+2. **Smart Autocomplete** - When typing app name, suggests existing apps from library
+3. **Prevent Duplicates** - If selecting existing app, shows "Add to profile and homepage →" button instead of submission form
+4. **Auto-Pin Flow** - Clicking button redirects back to profile with query params (`?pin=appId&addToProfile=true`)
+5. **Seamless Integration** - Profile detects params, pins to homepage AND adds to profile, then refreshes to show new app
+6. **API Endpoint** - `/api/profile/add-app` handles adding apps to `user_profile_apps` table
+
+**User Flow:**
+- Visit your profile → Click "+ Add App" → Search app name → Select existing app
+- Click "Add to profile and homepage →" → Redirects back to profile
+- App now appears on profile AND pinned on homepage
+
+**Technical Details:**
+- `AppsList.tsx`: Conditional rendering based on `isOwnProfile` prop
+- `AppSubmissionForm.tsx`: New `fromProfile` prop changes behavior for existing apps
+- `PersonalProfile.tsx` & `DevProfileMock.tsx`: Handle query params with `useEffect`
+- `/api/profile/add-app.ts`: Edge function to insert into `user_profile_apps`
+- URL cleanup: Query params removed after processing via `history.replaceState`
+
+---
+
## Recent Improvements (2026-02-12)
### App Submission UX Overhaul
@@ -351,6 +416,7 @@ mkdir extension
## UGC Workflow
+### From Dashboard/Homepage
1. User clicks + icon (homepage or dashboard)
2. Modal opens with app submission form
3. User types app name → autocomplete suggests existing apps to prevent duplicates
@@ -360,6 +426,19 @@ mkdir extension
7. Admin reviews via Drizzle Studio
8. Approved → visible globally in app directory
+### From Profile Page (NEW)
+1. User visits their own profile → Sees "+ Add App" button (if no apps yet)
+2. Clicks button → Modal opens with submission form
+3. Types app name → Autocomplete shows existing apps
+4. **If existing app selected:**
+ - Shows "Add to profile and homepage →" button
+ - Clicking redirects to profile with query params
+ - App automatically added to profile + pinned to homepage
+5. **If new app entered:**
+ - Submits for approval (same as dashboard flow)
+ - App appears in dashboard immediately (pending approval)
+6. Profile refreshes to show newly added app
+
---
## Decisions Made
diff --git a/package-lock.json b/package-lock.json
index b9cd7c3..4a46b5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
+ "@types/qrcode": "^1.5.6",
"cheerio": "^1.2.0",
"concurrently": "^9.2.1",
"cors": "^2.8.6",
@@ -17,6 +18,7 @@
"drizzle-orm": "^0.45.1",
"express": "^5.2.1",
"node-fetch": "^2.7.0",
+ "qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
@@ -2981,6 +2983,15 @@
"pg-types": "^2.2.0"
}
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
@@ -5295,6 +5306,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -5672,6 +5692,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -5712,6 +5741,12 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -6962,6 +6997,19 @@
"node": ">= 0.8"
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -7818,6 +7866,18 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8287,6 +8347,42 @@
"node": ">=8"
}
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/parse-ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz",
@@ -8362,6 +8458,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -8475,6 +8580,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -8608,6 +8722,89 @@
"node": ">=6"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -8786,6 +8983,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -9124,6 +9327,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -10487,6 +10696,12 @@
"node": ">= 8"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index f275825..01a2fab 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"description": "",
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
+ "@types/qrcode": "^1.5.6",
"cheerio": "^1.2.0",
"concurrently": "^9.2.1",
"cors": "^2.8.6",
@@ -29,6 +30,7 @@
"drizzle-orm": "^0.45.1",
"express": "^5.2.1",
"node-fetch": "^2.7.0",
+ "qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
diff --git a/public/mark_green.svg b/public/mark_green.svg
new file mode 100644
index 0000000..7ab06c0
--- /dev/null
+++ b/public/mark_green.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 9ed8f8b..3dd828b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,9 +5,11 @@ import { PinButton } from './components/ui/PinButton';
import { LaunchButton } from './components/ui/LaunchButton';
import { Modal } from './components/ui/Modal';
import { AppSubmissionForm } from './components/AppSubmissionForm';
+import { ToastContainer } from './components/ToastContainer';
import { usePreferences } from './hooks/usePreferences';
import { useSwipe } from './hooks/useSwipe';
import { useAuth } from './hooks/useAuth';
+import { useToast } from './hooks/useToast';
// ── Types ──────────────────────────────────────────────
@@ -231,7 +233,8 @@ function PinnedAppCard({
}) {
const swipeProps = useSwipe({
onSwipeLeft: () => {}, // Show menu on swipe left
- threshold: 100
+ threshold: 100,
+ autoCloseDelay: 1500
});
const handleClick = (e: React.MouseEvent) => {
@@ -498,16 +501,24 @@ function AppGridCard({
}) {
const swipeProps = useSwipe({
onSwipeLeft: () => {}, // Show menu on swipe left
- threshold: 100
+ threshold: 100,
+ autoCloseDelay: 1500
});
- const handleClick = () => {
+ const handleClick = (e: React.MouseEvent) => {
// If swipe menu is open, close it instead of selecting
if (swipeProps.isSwipeMenuOpen) {
swipeProps.closeSwipeMenu();
return;
}
- onSelect(app);
+
+ // Call swipe onClick first to handle any touch/swipe prevention
+ swipeProps.handlers.onClick(e);
+
+ // Only proceed if the event wasn't prevented by swipe handler
+ if (!e.defaultPrevented) {
+ onSelect(app);
+ }
};
return (
@@ -901,9 +912,9 @@ function AppDetailSidebar({
}) {
return (
<>
- {app &&
}
+ {app &&
}