diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..919396a3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added - Saved Repos Feature + +#### Frontend +- **Zustand Store**: Created `useSavedProjectsStore` with localStorage persistence + - Stores saved repositories with key `oss_saved_repos_v1` + - Actions: `addProject`, `removeProject`, `toggleProject`, `clearAllSaved`, `setAll`, `isSaved` + - Automatic persistence across page reloads + +- **UI Components**: + - `SaveToggle`: Star icon button in project rows to save/unsave repositories + - `SavedProjectsPanel`: Side panel for managing saved repos + - Export saved repos to JSON file + - Import saved repos from JSON file + - Clear all saved repos with confirmation + - View all saved repos with metadata + +- **Projects Table**: Added "Save" column as first column in OSS Projects table +- **Header Button**: Added "Saved Projects" button with count badge in projects page header + +#### Backend +- **Database**: Added `saved_repos` JSONB column to `User` model (default: `[]`) +- **Service Layer**: Created `savedReposService` with: + - `getSavedRepos`: Retrieve user's saved repos + - `mergeSavedRepos`: Merge local and server repos with conflict resolution + - `updateSavedRepos`: Update saved repos with add/remove/replace actions + - Maximum 100 saved repos per user enforcement + +- **API Endpoints** (tRPC): + - `user.getSavedRepos`: Get user's saved repos (protected, feature flag: `FEATURE_SAVED_REPOS_DB`) + - `user.updateSavedRepos`: Update saved repos with merge logic (protected, feature flag: `FEATURE_SAVED_REPOS_DB`) + - Conflict resolution: Newer `savedAt` timestamp wins + +#### Shared Types +- Created `SavedRepo` type definition in `@opensox/shared` +- Created `SavedReposAction` and `SavedReposUpdateInput` types + +### Configuration +- **Feature Flag**: `FEATURE_SAVED_REPOS_DB` - Enable/disable database sync (default: disabled) + - When disabled: Client-only mode with localStorage + - When enabled: Full sync across devices with merge logic + +### Migration +- Migration file: `add_saved_repos` - Adds `saved_repos` JSONB column to User table + +--- + +## How to Use + +### For Users +1. Navigate to `/dashboard/projects` +2. Click "Find projects" to search for repositories +3. Click the star icon on any project to save it +4. Click "Saved Projects" button to view/manage saved repos +5. Export/import saved repos as JSON for backup + +### For Developers +1. **Client-only mode** (default): Works out of the box with localStorage +2. **Database sync mode**: Set `FEATURE_SAVED_REPOS_DB=true` in `apps/api/.env` +3. Run migration: `cd apps/api && npx prisma migrate dev` +4. Restart API server + +### API Usage (when feature flag enabled) +```typescript +// Get saved repos +const savedRepos = await trpc.user.getSavedRepos.query(); + +// Add repos +await trpc.user.updateSavedRepos.mutate({ + action: 'add', + repos: [{ id: '123', name: 'repo', url: 'https://...', savedAt: new Date().toISOString() }] +}); + +// Sync with merge +await trpc.user.updateSavedRepos.mutate({ + action: 'replace', + repos: serverRepos, + localRepos: clientRepos // Will merge and resolve conflicts +}); +``` diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..d1a1753d --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,32 @@ +# Base stage with pnpm +FROM node:20-alpine AS base +RUN npm install -g pnpm +WORKDIR /app + +# API development +FROM base AS api +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY apps/api/package.json ./apps/api/ +COPY packages/shared/package.json ./packages/shared/ +RUN pnpm install +COPY packages/shared ./packages/shared +COPY apps/api ./apps/api +WORKDIR /app/packages/shared +RUN pnpm run build +WORKDIR /app/apps/api +RUN pnpm exec prisma generate +EXPOSE 8080 +CMD ["pnpm", "run", "dev"] + +# Web development +FROM base AS web +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/ui/package.json ./packages/ui/ +RUN pnpm install +COPY packages ./packages +COPY apps/web ./apps/web +WORKDIR /app/apps/web +EXPOSE 3000 +CMD ["pnpm", "run", "dev"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..b14bad58 --- /dev/null +++ b/Makefile @@ -0,0 +1,128 @@ +.PHONY: help start stop restart logs clean reset setup dev test status health migrate seed studio shell-api shell-db dev-local logs-api logs-web logs-db migrate-dev + +# Default target - show help +help: + @echo "" + @echo "========================================================================" + @echo " Opensox Development CLI " + @echo "========================================================================" + @echo "" + @echo " GETTING STARTED:" + @echo " make setup - First-time setup wizard (run this first!)" + @echo " make start - Start all services (DB, API, Web)" + @echo "" + @echo " SERVICE MANAGEMENT:" + @echo " make stop - Stop all services" + @echo " make restart - Restart all services" + @echo " make logs - View logs from all services" + @echo " make status - Check service health" + @echo "" + @echo " DATABASE:" + @echo " make migrate - Run database migrations" + @echo " make seed - Seed database with initial data" + @echo " make studio - Open Prisma Studio (GUI)" + @echo " make reset - Stop services and reset database (DELETES DATA)" + @echo "" + @echo " DEBUGGING:" + @echo " make shell-api - Open shell in API container" + @echo " make shell-db - Open psql shell in database" + @echo " make logs-api - View API logs only" + @echo " make logs-web - View Web logs only" + @echo "" + @echo " CLEANUP:" + @echo " make clean - Remove containers, volumes, and build cache" + @echo "" + @echo "========================================================================" + @echo "" + +# First-time setup +setup: + @echo "Starting Opensox setup wizard..." + @chmod +x ./scripts/setup.sh 2>/dev/null || true + @./scripts/setup.sh + +# Start all services +start: + @echo "Starting all services..." + docker compose up -d + @$(MAKE) --no-print-directory health + @echo "" + @echo "Services started successfully!" + @echo " Frontend: http://localhost:3000" + @echo " API: http://localhost:8080" + @echo "" + +# Stop all services +stop: + @echo "Stopping all services..." + docker compose down + +# Restart services +restart: stop start + +# View logs +logs: + docker compose logs -f + +logs-api: + docker compose logs -f api + +logs-web: + docker compose logs -f web + +logs-db: + docker compose logs -f postgres + +# Check status +status: + @docker compose ps + +# Health check +health: + @echo "Waiting for services to be healthy..." + @sleep 3 + @docker compose ps --format "table {{.Name}}\t{{.Status}}" | head -10 + +# Database operations +migrate: + @echo "Running database migrations..." + docker compose exec api pnpm exec prisma migrate deploy + +migrate-dev: + docker compose exec api pnpm exec prisma migrate dev + +seed: + @echo "Seeding database..." + docker compose exec api pnpm exec prisma db seed + +studio: + @echo "Opening Prisma Studio..." + docker compose exec api pnpm exec prisma studio + +# Reset database +reset: + @echo "WARNING: This will delete all data. Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ] + docker compose down -v + @$(MAKE) --no-print-directory start + @$(MAKE) --no-print-directory migrate + @$(MAKE) --no-print-directory seed + +# Clean everything +clean: + @echo "Cleaning up..." + docker compose down -v --rmi local --remove-orphans + rm -rf apps/api/node_modules apps/web/node_modules + rm -rf apps/api/dist apps/web/.next + +# Shell access +shell-api: + docker compose exec api sh + +shell-db: + docker compose exec postgres psql -U opensox -d opensox + +# Development without Docker (uses local Node) +dev-local: + @echo "Starting local development..." + pnpm install + pnpm run dev diff --git a/README.md b/README.md index d51d2650..5362caa7 100644 --- a/README.md +++ b/README.md @@ -38,234 +38,166 @@ We love our contributors! Here’s how you can contribute: - [Open an issue](https://github.com/apsinghdev/opensox/issues) if you believe you’ve encountered a bug. - Make a [pull request](https://github.com/apsinghdev/opensox/pulls) to add new features, improve quality of life, or fix bugs. -### Setting up locally +## Quick Start (Docker) 🐳 -Opensox AI's stack consists of the following elements: +Get up and running in **3 steps**: -- A backend API built with tRPC and Express.js -- A frontend written in Next.js and TypeScript -- A PostgreSQL database -- A Redis database (in process) -- What's for AI? Coming very soon… - -#### Prerequisites - -Opensox needs [TypeScript](https://www.typescriptlang.org/download/) and [Node.js >= 18](https://nodejs.org/en/download/package-manager) installations. - -## Setup environment variables - -Create environment files for both the backend and the frontend before running the apps. - -### Backend (`apps/api/.env`) +### Prerequisites -Copy the example environment file and update it with your values: +- [Docker](https://docs.docker.com/get-docker/) installed and running +- [Make](https://www.gnu.org/software/make/) (pre-installed on Mac/Linux) +### 1. Clone the repository ```bash -cd apps/api -cp .env.example .env +git clone https://github.com/apsinghdev/opensox.git +cd opensox ``` -Then edit `apps/api/.env` and fill in the required values: - +### 2. Run setup ```bash -# Required -DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/opensox?schema=public" -JWT_SECRET="replace-with-a-strong-random-secret" - -# Optional (good defaults shown) -PORT=8080 -CORS_ORIGINS=http://localhost:3000 -NODE_ENV=development - -# Optional but needed for GitHub queries to work -# Generate a classic token with "public_repo" access at https://github.com/settings/tokens -GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -``` - -Notes: -- DATABASE_URL: point this to your PostgreSQL instance. Example for local Postgres is shown above. -- JWT_SECRET: generate one, e.g. `openssl rand -base64 32` or `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. -- CORS_ORIGINS: comma-separated list of allowed origins. Keep `http://localhost:3000` for local web app. - -### Frontend (`apps/web/.env.local`) - -Create a file at `apps/web/.env.local` with: +# Mac/Linux +make setup -```bash -# Required -NEXT_PUBLIC_API_URL="http://localhost:8080" -GOOGLE_CLIENT_ID="your-google-oauth-client-id" -GOOGLE_CLIENT_SECRET="your-google-oauth-client-secret" -NEXTAUTH_SECRET="replace-with-a-strong-random-secret" - -# Recommended for production (optional for local dev) -NEXTAUTH_URL="http://localhost:3000" - -# Optional analytics (PostHog) -# If you don't use PostHog, you can omit these -# NEXT_PUBLIC_POSTHOG_KEY="phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -# NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" # or https://app.posthog.com +# Windows (PowerShell) +.\setup.ps1 ``` -Notes: -- NEXT_PUBLIC_API_URL must point to the backend base URL; the frontend calls `${NEXT_PUBLIC_API_URL}/api/...` for auth and data. -- Google OAuth: create credentials in Google Cloud Console (OAuth 2.0 Client ID), add Authorized redirect URIs for NextAuth (e.g., `http://localhost:3000/api/auth/callback/google`). -- NEXTAUTH_SECRET: generate one just like `JWT_SECRET`. It’s used by NextAuth; it can be different from the backend secret. - -After creating these files, restart your dev servers so changes take effect. +This will automatically: +- βœ… Create environment files from templates +- βœ… Generate secure secrets +- βœ… Start all services (Database, API, Web) +- βœ… Run database migrations +- βœ… Seed initial data +### 3. Configure Google OAuth (Required for login) -## Database setup (migrations and seed) +1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +2. Create a new project (or use existing) +3. Configure OAuth consent screen +4. Create OAuth 2.0 Client ID (Web application) +5. Add redirect URI: `http://localhost:3000/api/auth/callback/google` +6. Copy credentials to `apps/web/.env.local`: + ``` + GOOGLE_CLIENT_ID="your-client-id" + GOOGLE_CLIENT_SECRET="your-client-secret" + ``` +7. Restart: `make restart` -Run these steps once your `DATABASE_URL` is set: - -```bash -cd apps/api +**Done! πŸŽ‰** Visit http://localhost:3000 -# Generate Prisma client (optional if already generated) -npx prisma generate +--- -# Apply migrations locally -npx prisma migrate dev --name init -``` +## Common Commands -Seed initial data so features relying on it work correctly: +| Command | Description | +|---------|-------------| +| `make start` | Start all services | +| `make stop` | Stop all services | +| `make logs` | View live logs | +| `make status` | Check service health | +| `make reset` | Reset database (deletes data) | +| `make studio` | Open Prisma Studio (DB GUI) | +| `make help` | Show all available commands | -- Option A (recommended): use Prisma Studio to insert the initial row +--- - ```bash - npx prisma studio - ``` +## Troubleshooting - Then open the `QueryCount` model and create a row with: - - `id`: 1 - - `total_queries`: 0 +
+πŸ”΄ "Cannot connect to Docker daemon" -- Option B: insert directly via SQL (replace the connection string as needed) +Make sure Docker Desktop is running. +
- ```bash - psql "postgresql://USER:PASSWORD@localhost:5432/opensox" \ - -c "INSERT INTO \"QueryCount\" (id, total_queries) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;" - ``` - - -## Setup environment - -1. Fork and clone the opensox repo +
+πŸ”΄ "Port 3000/8080 is already in use" +Stop the process using the port: ```bash -git clone https://github.com/[your-github-username]/opensox.git -``` +# Find process (Mac/Linux) +lsof -i :3000 -2. `cd` into `opensox/apps/web` and install dependencies - -```bash -pnpm install +# Windows PowerShell +netstat -ano | findstr :3000 ``` +
-Now run the dev server: +
+πŸ”΄ "OAuth error: redirect_uri_mismatch" -```bash -pnpm run dev -``` - -Congrats! Your frontend is running on `localhost:3000`. +Ensure your Google OAuth redirect URI exactly matches: +`http://localhost:3000/api/auth/callback/google` +
-3. `cd` into `opensox/apps/api` and install dependencies +
+πŸ”΄ Database connection errors +Reset the database: ```bash -npm install +make reset ``` +
-Now run the server: - -```bash -pnpm run dev -``` +--- -Voila! Your API server is running on `localhost:4000`. +
+

Manual Setup (without Docker)

-Now you can access your app at `http://localhost:3000`. +### Prerequisites -## Running the API with Docker +Opensox needs [TypeScript](https://www.typescriptlang.org/download/) and [Node.js >= 18](https://nodejs.org/en/download/package-manager) installations. -Alternatively, you can run the API server using Docker. A `Dockerfile` is provided in the root directory. +### Setup environment variables -### Prerequisites +Create environment files for both the backend and the frontend before running the apps. -- [Docker](https://docs.docker.com/get-docker/) installed on your machine +#### Backend (`apps/api/.env`) -### Building and Running +```bash +cd apps/api +cp .env.example .env +``` -1. Make sure you have your `.env` file set up in `apps/api/.env`. You can copy from `.env.example` (see [Backend environment variables](#backend-appsapienv) section above) +Edit `apps/api/.env` and fill in: +- `DATABASE_URL` - Your PostgreSQL connection string +- `JWT_SECRET` - Generate with `openssl rand -base64 32` -2. From the root directory, build the Docker image: +#### Frontend (`apps/web/.env.local`) ```bash -docker build -t opensox-api . +cd apps/web +cp .env.example .env.local ``` -3. Run the container with your environment variables: +Edit `apps/web/.env.local` and fill in your Google OAuth credentials. + +### Database setup ```bash -docker run -p 4000:4000 \ - --env-file apps/api/.env \ - opensox-api +cd apps/api +npx prisma generate +npx prisma migrate dev --name init +npx prisma db seed ``` -Or if you prefer to pass environment variables individually: +### Run the servers ```bash -docker run -p 4000:4000 \ - -e DATABASE_URL="postgresql://USER:PASSWORD@host.docker.internal:5432/opensox?schema=public" \ - -e JWT_SECRET="your-secret" \ - -e PORT=4000 \ - opensox-api -``` +# Terminal 1 - API +cd apps/api +pnpm install +pnpm run dev -**Note:** When using Docker, if your database is running on your host machine (not in a container), use `host.docker.internal` instead of `localhost` in your `DATABASE_URL`. - -Your API server will be available at `http://localhost:4000`. - -### Using Docker Compose (Optional) - -For a complete setup with PostgreSQL, you can create a `docker-compose.yml` file: - -```yaml -version: '3.8' -services: - postgres: - image: postgres:15 - environment: - POSTGRES_USER: opensox - POSTGRES_PASSWORD: opensox - POSTGRES_DB: opensox - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - api: - build: . - ports: - - "4000:4000" - environment: - DATABASE_URL: postgresql://opensox:opensox@postgres:5432/opensox?schema=public - JWT_SECRET: your-secret-key - PORT: 4000 - NODE_ENV: production - depends_on: - - postgres - -volumes: - postgres_data: +# Terminal 2 - Web +cd apps/web +pnpm install +pnpm run dev ``` -Then run: +Frontend: http://localhost:3000 | API: http://localhost:8080 + +
-```bash -docker-compose up -d -``` ## Our contributors diff --git a/apps/api/package.json b/apps/api/package.json index 0c6722ac..44255189 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,9 @@ "author": "Ajeet Pratpa Singh", "license": "ISC", "packageManager": "pnpm@10.11.0", + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^4.17.21", @@ -38,8 +41,5 @@ "superjson": "^2.2.5", "zeptomail": "^6.2.1", "zod": "^4.1.9" - }, - "prisma": { - "seed": "tsx prisma/seed.ts" } -} +} \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2bf9695f..e18e8886 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { createdAt DateTime @default(now()) lastLogin DateTime @updatedAt completedSteps Json? + saved_repos Json @default("[]") accounts Account[] payments Payment[] subscriptions Subscription[] diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 1f6b186c..6fd888b8 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -62,25 +62,25 @@ async function main() { const premiumSubscription = existingSubscription ? await prisma.subscription.update({ - where: { id: existingSubscription.id }, - data: { - planId: testPlan.id, - status: 'active', - startDate: new Date(), - endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now - autoRenew: true, - }, - }) + where: { id: existingSubscription.id }, + data: { + planId: testPlan.id, + status: 'active', + startDate: new Date(), + endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now + autoRenew: true, + }, + }) : await prisma.subscription.create({ - data: { - userId: premiumUser.id, - planId: testPlan.id, - status: 'active', - startDate: new Date(), - endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now - autoRenew: true, - }, - }); + data: { + userId: premiumUser.id, + planId: testPlan.id, + status: 'active', + startDate: new Date(), + endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now + autoRenew: true, + }, + }); console.log('βœ… Created/updated premium subscription'); // Create test payment @@ -127,4 +127,3 @@ main() .finally(async () => { await prisma.$disconnect(); }); - diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f1925322..1dc676ad 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -98,6 +98,11 @@ app.get("/test", apiLimiter, (req: Request, res: Response) => { res.status(200).json({ status: "ok", message: "Test endpoint is working" }); }); +// Health check endpoint for Docker +app.get("/health", (req: Request, res: Response) => { + res.status(200).json({ status: "ok", timestamp: new Date().toISOString() }); +}); + // Slack Community Invite Endpoint (Protected) app.get("/join-community", apiLimiter, async (req: Request, res: Response) => { try { diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 94205d01..2ede16d5 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -1,5 +1,6 @@ import { router, publicProcedure, protectedProcedure } from "../trpc.js"; import { userService } from "../services/user.service.js"; +import { savedReposService } from "../services/savedRepos.service.js"; import { z } from "zod"; export const userRouter = router({ @@ -34,5 +35,81 @@ export const userRouter = router({ userId, input.completedSteps ); + }), + + // get user's saved repos (feature flag: FEATURE_SAVED_REPOS_DB) + getSavedRepos: protectedProcedure.query(async ({ ctx }: any) => { + if (process.env.FEATURE_SAVED_REPOS_DB !== "true") { + return []; + } + const userId = ctx.user.id; + return await savedReposService.getSavedRepos(ctx.db.prisma, userId); }), + + // update user's saved repos with merge logic (feature flag: FEATURE_SAVED_REPOS_DB) + updateSavedRepos: protectedProcedure + .input( + z.object({ + action: z.enum(["add", "remove", "replace"]), + repos: z.array( + z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + language: z.string().optional(), + popularity: z.enum(["low", "medium", "high"]).optional(), + competitionScore: z.number().optional(), + savedAt: z.string(), + meta: z.record(z.string(), z.unknown()).optional(), + }) + ), + localRepos: z + .array( + z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + language: z.string().optional(), + popularity: z.enum(["low", "medium", "high"]).optional(), + competitionScore: z.number().optional(), + savedAt: z.string(), + meta: z.record(z.string(), z.unknown()).optional(), + }) + ) + .optional(), + }) + ) + .mutation(async ({ ctx, input }: any) => { + if (process.env.FEATURE_SAVED_REPOS_DB !== "true") { + throw new Error("Saved repos sync is not enabled"); + } + + const userId = ctx.user.id; + + // If localRepos provided, merge with server repos + if (input.localRepos && input.action === "replace") { + const serverRepos = await savedReposService.getSavedRepos( + ctx.db.prisma, + userId + ); + const merged = savedReposService.mergeSavedRepos( + input.localRepos, + serverRepos + ); + return await savedReposService.updateSavedRepos( + ctx.db.prisma, + userId, + "replace", + merged + ); + } + + // Otherwise, perform the requested action + return await savedReposService.updateSavedRepos( + ctx.db.prisma, + userId, + input.action, + input.repos + ); + }), }); diff --git a/apps/api/src/services/savedRepos.service.ts b/apps/api/src/services/savedRepos.service.ts new file mode 100644 index 00000000..adaf2c52 --- /dev/null +++ b/apps/api/src/services/savedRepos.service.ts @@ -0,0 +1,115 @@ +import type { PrismaClient } from "@prisma/client"; +import type { ExtendedPrismaClient } from "../prisma.js"; +import type { SavedRepo } from "@opensox/shared"; + +export const savedReposService = { + /** + * Get user's saved repos + */ + async getSavedRepos( + prisma: ExtendedPrismaClient | PrismaClient, + userId: string + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { saved_repos: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const savedRepos = user.saved_repos as SavedRepo[] | null; + return savedRepos || []; + }, + + /** + * Merge local and server saved repos + * Resolves conflicts by keeping the newer version (based on savedAt timestamp) + */ + mergeSavedRepos(local: SavedRepo[], server: SavedRepo[]): SavedRepo[] { + const merged = new Map(); + + // Add all server repos + for (const repo of server) { + merged.set(repo.id, repo); + } + + // Add or update with local repos (newer wins) + for (const repo of local) { + const existing = merged.get(repo.id); + if (!existing || new Date(repo.savedAt) > new Date(existing.savedAt)) { + merged.set(repo.id, repo); + } + } + + return Array.from(merged.values()).sort( + (a, b) => + new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime() + ); + }, + + /** + * Update user's saved repos + */ + async updateSavedRepos( + prisma: ExtendedPrismaClient | PrismaClient, + userId: string, + action: "add" | "remove" | "replace", + repos: SavedRepo[] + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { saved_repos: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + let currentRepos = (user.saved_repos as SavedRepo[]) || []; + let updatedRepos: SavedRepo[]; + + switch (action) { + case "add": { + // Add new repos, skip duplicates + const existingIds = new Set(currentRepos.map((r) => r.id)); + const newRepos = repos.filter((r) => !existingIds.has(r.id)); + updatedRepos = [...currentRepos, ...newRepos]; + break; + } + + case "remove": { + // Remove repos by ID + const removeIds = new Set(repos.map((r) => r.id)); + updatedRepos = currentRepos.filter((r) => !removeIds.has(r.id)); + break; + } + + case "replace": { + // Replace entire list (for sync) + updatedRepos = repos; + break; + } + + default: + throw new Error(`Invalid action: ${action}`); + } + + // Enforce maximum 100 saved repos + if (updatedRepos.length > 100) { + throw new Error("Maximum 100 saved repos allowed"); + } + + // Update database + const updated = await prisma.user.update({ + where: { id: userId }, + data: { + saved_repos: updatedRepos as any, // Cast to satisfy Prisma's Json type + }, + select: { saved_repos: true }, + }); + + return (updated.saved_repos as SavedRepo[]) || []; + }, +}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aa78dc6f..0a5e5e93 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -4,7 +4,6 @@ // File Layout "rootDir": "./src", "outDir": "./dist", - // Environment Settings // See also https://aka.ms/tsconfig/module "module": "nodenext", @@ -13,16 +12,13 @@ // "lib": ["esnext"], // "types": ["node"], // and npm install -D @types/node - // Other Outputs "sourceMap": true, "declaration": true, "declarationMap": true, - // Stricter Typechecking Options "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, - // Style Options // "noImplicitReturns": true, // "noImplicitOverride": true, @@ -30,7 +26,6 @@ // "noUnusedParameters": true, // "noFallthroughCasesInSwitch": true, // "noPropertyAccessFromIndexSignature": true, - // Recommended Options "strict": true, "jsx": "react-jsx", @@ -48,4 +43,4 @@ "node_modules", "dist" ] -} +} \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..d39fe2a9 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,27 @@ +# ============================================================ +# OPENSOX WEB CONFIGURATION +# ============================================================ +# Copy this file to .env.local and fill in the values + +# -------------------- REQUIRED -------------------- +# API Connection (matches the docker-compose exposed port) +NEXT_PUBLIC_API_URL="http://localhost:8080" + +# NextAuth Configuration +# Generate with: openssl rand -base64 32 +NEXTAUTH_SECRET="replace-with-a-strong-random-secret" +NEXTAUTH_URL="http://localhost:3000" + +# -------------------- GOOGLE OAUTH (REQUIRED FOR LOGIN) -------------------- +# Create OAuth credentials at: https://console.cloud.google.com/apis/credentials +# 1. Create a new project (or use existing) +# 2. Configure OAuth consent screen (external, testing mode is fine) +# 3. Create OAuth 2.0 Client ID (Web application) +# 4. Add authorized redirect URI: http://localhost:3000/api/auth/callback/google +GOOGLE_CLIENT_ID="your-google-oauth-client-id" +GOOGLE_CLIENT_SECRET="your-google-oauth-client-secret" + +# -------------------- OPTIONAL -------------------- +# PostHog Analytics (leave commented out if not using) +# NEXT_PUBLIC_POSTHOG_KEY="phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +# NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 90975c82..4e968fb7 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -31,6 +31,7 @@ yarn-error.log* # env files (can opt-in for commiting if needed) .env* +!.env.example # vercel .vercel diff --git a/apps/web/src/components/dashboard/ProjectsContainer.tsx b/apps/web/src/components/dashboard/ProjectsContainer.tsx index 80e380ac..58211e25 100644 --- a/apps/web/src/components/dashboard/ProjectsContainer.tsx +++ b/apps/web/src/components/dashboard/ProjectsContainer.tsx @@ -16,6 +16,10 @@ import Image from "next/image"; import { useFilterStore } from "@/store/useFilterStore"; import { usePathname } from "next/navigation"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import SaveToggle from "./SaveToggle"; +import SavedProjectsPanel from "./SavedProjectsPanel"; +import { useSavedProjectsStore } from "@/store/useSavedProjectsStore"; +import { useState } from "react"; type ProjectsContainerProps = { projects: DashboardProjectsProps[] }; @@ -42,6 +46,7 @@ const getColor = (c?: string) => languageColors[(c || "").toLowerCase()] || "bg-gray-200/10 text-gray-300"; const tableColumns = [ + "Save", "Project", "Issues", "Language", @@ -57,6 +62,8 @@ export default function ProjectsContainer({ const pathname = usePathname(); const { projectTitle } = useProjectTitleStore(); const { setShowFilters } = useFilterStore(); + const { savedProjects } = useSavedProjectsStore(); + const [showSavedPanel, setShowSavedPanel] = useState(false); const isProjectsPage = pathname === "/dashboard/projects"; return ( @@ -66,12 +73,39 @@ export default function ProjectsContainer({ {projectTitle} {isProjectsPage && ( - +
+ + +
)} @@ -115,6 +149,10 @@ export default function ProjectsContainer({ className="border-y border-ox-gray cursor-pointer hover:bg-white/5 transition-colors" onClick={() => window.open(p.url, "_blank")} > + + + +
@@ -174,6 +212,11 @@ export default function ProjectsContainer({

) : null} + + setShowSavedPanel(false)} + />
); } diff --git a/apps/web/src/components/dashboard/SaveToggle.tsx b/apps/web/src/components/dashboard/SaveToggle.tsx new file mode 100644 index 00000000..268b7dad --- /dev/null +++ b/apps/web/src/components/dashboard/SaveToggle.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useSavedProjectsStore } from "@/store/useSavedProjectsStore"; +import { DashboardProjectsProps } from "@/types"; +import { SavedRepo } from "@opensox/shared"; +import { StarIcon } from "@heroicons/react/24/solid"; +import { StarIcon as StarOutlineIcon } from "@heroicons/react/24/outline"; + +interface SaveToggleProps { + project: DashboardProjectsProps; +} + +export default function SaveToggle({ project }: SaveToggleProps) { + const { toggleProject, isSaved } = useSavedProjectsStore(); + const saved = isSaved(project.id); + + // Type guard to validate popularity value + const isValidPopularity = ( + value: string | undefined + ): value is "low" | "medium" | "high" => { + return value === "low" || value === "medium" || value === "high"; + }; + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent row click event + + const savedRepo: SavedRepo = { + id: project.id, + name: project.name, + url: project.url, + language: project.primaryLanguage, + popularity: isValidPopularity(project.popularity) + ? project.popularity + : undefined, + competitionScore: parseFloat(project.competition) || 0, + savedAt: new Date().toISOString(), + meta: { + avatarUrl: project.avatarUrl, + description: project.description, + totalIssueCount: project.totalIssueCount, + stage: project.stage, + activity: project.activity, + }, + }; + + toggleProject(savedRepo); + }; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/dashboard/SavedProjectsPanel.tsx b/apps/web/src/components/dashboard/SavedProjectsPanel.tsx new file mode 100644 index 00000000..3c36073e --- /dev/null +++ b/apps/web/src/components/dashboard/SavedProjectsPanel.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useRef, useState } from "react"; +import type { ChangeEvent } from "react"; +import { + XMarkIcon, + ArrowDownTrayIcon, + ArrowUpTrayIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useSavedProjectsStore } from "@/store/useSavedProjectsStore"; + +interface SavedProjectsPanelProps { + isOpen: boolean; + onClose: () => void; +} + +export default function SavedProjectsPanel({ + isOpen, + onClose, +}: SavedProjectsPanelProps) { + const { savedProjects, clearAllSaved, removeProject, importAndValidate } = + useSavedProjectsStore(); + const fileInputRef = useRef(null); + const [showClearConfirm, setShowClearConfirm] = useState(false); + + const handleExport = () => { + const dataStr = JSON.stringify(savedProjects, null, 2); + const dataBlob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement("a"); + link.href = url; + link.download = `saved-repos-${new Date().toISOString().split("T")[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleImport = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const imported = JSON.parse(event.target?.result as string); + const result = importAndValidate(imported); + + if (result.success) { + alert(result.error || "Projects imported successfully!"); + } else { + alert(result.error || "Failed to import projects."); + } + } catch (error) { + console.error("Failed to import saved repos:", error); + alert("Failed to import file. Please ensure it's a valid JSON file."); + } + }; + reader.readAsText(file); + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleClearAll = () => { + if (showClearConfirm) { + clearAllSaved(); + setShowClearConfirm(false); + } else { + setShowClearConfirm(true); + setTimeout(() => setShowClearConfirm(false), 3000); + } + }; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +