Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
});
```
1 change: 1 addition & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
77 changes: 77 additions & 0 deletions apps/api/src/routers/user.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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
);
}),
});
115 changes: 115 additions & 0 deletions apps/api/src/services/savedRepos.service.ts
Original file line number Diff line number Diff line change
@@ -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<SavedRepo[]> {
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<string, SavedRepo>();

// 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<SavedRepo[]> {
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[]) || [];
},
};
55 changes: 49 additions & 6 deletions apps/web/src/components/dashboard/ProjectsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };

Expand All @@ -42,6 +46,7 @@ const getColor = (c?: string) =>
languageColors[(c || "").toLowerCase()] || "bg-gray-200/10 text-gray-300";

const tableColumns = [
"Save",
"Project",
"Issues",
"Language",
Expand All @@ -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 (
Expand All @@ -66,12 +73,39 @@ export default function ProjectsContainer({
{projectTitle}
</h2>
{isProjectsPage && (
<Button
className="font-semibold text-text-primary bg-ox-purple text-sm sm:text-base h-10 sm:h-11 px-5 sm:px-6 hover:bg-white-500 rounded-md"
onClick={() => setShowFilters(true)}
>
Find projects
</Button>
<div className="flex gap-2">
<Button
className="font-semibold text-text-primary bg-ox-purple text-sm sm:text-base h-10 sm:h-11 px-5 sm:px-6 hover:bg-ox-purple/80 rounded-md"
onClick={() => setShowSavedPanel(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") e.preventDefault();
setShowSavedPanel(true);
}
}}
aria-label="View saved projects"
>
Saved Projects
{savedProjects.length > 0 && (
<Badge className="ml-2 bg-white text-ox-purple">
{savedProjects.length}
</Badge>
)}
</Button>
<Button
className="font-semibold text-text-primary bg-ox-purple text-sm sm:text-base h-10 sm:h-11 px-5 sm:px-6 hover:bg-ox-purple/80 rounded-md"
onClick={() => setShowFilters(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") e.preventDefault();
setShowFilters(true);
}
}}
aria-label="Open project filters"
>
Find projects
</Button>
</div>
)}
</div>

Expand Down Expand Up @@ -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")}
>
<TableCell className="p-1 sm:p-2 text-center">
<SaveToggle project={p} />
</TableCell>

<TableCell className="p-1 sm:p-2">
<div className="flex items-center gap-2">
<div className="rounded-full overflow-hidden inline-block h-4 w-4 sm:h-6 sm:w-6 border">
Expand Down Expand Up @@ -174,6 +212,11 @@ export default function ProjectsContainer({
</p>
</div>
) : null}

<SavedProjectsPanel
isOpen={showSavedPanel}
onClose={() => setShowSavedPanel(false)}
/>
</div>
);
}
Loading