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/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/src/routers/user.ts b/apps/api/src/routers/user.ts index 94205d01..50d5d6e4 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.any()).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.any()).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..e706767f --- /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, + }, + select: { saved_repos: true }, + }); + + return (updated.saved_repos as SavedRepo[]) || []; + }, +}; diff --git a/apps/web/src/components/dashboard/ProjectsContainer.tsx b/apps/web/src/components/dashboard/ProjectsContainer.tsx index 80e380ac..9f886377 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..07a4d05e --- /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 */} +