From e9e380423b7cb0cb689964fb58bb01e3cbf46adf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:00:35 +0000 Subject: [PATCH 1/3] Initial plan From 1ea9dfe8488083f5cd23f320115a9e45221ee6c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:09:13 +0000 Subject: [PATCH 2/3] Fix: separate event handlers from state in useSwipe hook Co-authored-by: ghostleek <44336310+ghostleek@users.noreply.github.com> --- package-lock.json | 12 ++++++++++++ src/App.tsx | 10 +++++++--- src/hooks/useSwipe.ts | 6 +++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03f179a..b9cd7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2953,6 +2954,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2985,6 +2987,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4854,6 +4857,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5220,6 +5224,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6590,6 +6595,7 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8489,6 +8495,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8680,6 +8687,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8689,6 +8697,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8872,6 +8881,7 @@ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9692,6 +9702,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10345,6 +10356,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/App.tsx b/src/App.tsx index 4b848e7..9ed8f8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -243,7 +243,7 @@ function PinnedAppCard({ } // Allow normal navigation - swipeProps.onClick(e); + swipeProps.handlers.onClick(e); }; return ( @@ -256,7 +256,9 @@ function PinnedAppCard({ '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} onClick={handleClick} >
@@ -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 &&
}