Skip to content

Commit 20fbf29

Browse files
authored
Merge pull request #836 from ayinde38/feat/clips-management-and-seo
feat: bulk clip delete/archive, dynamic OG images, robots/sitemap coverage, dependabot policy
2 parents 510a935 + f2093c4 commit 20fbf29

17 files changed

Lines changed: 1719 additions & 97 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"clipsproject": minor
3+
---
4+
5+
Add bulk delete and archive for clips, dynamic Open Graph images, verified robots/sitemap coverage, and a split Dependabot policy.
6+
7+
- `DELETE /api/clips` soft-deletes clips (`deletedAt`) and `PATCH /api/clips/archive` archives them (`archivedAt`); both are exposed from `SelectionFooter`, with deletion gated behind a confirmation.
8+
- Projects page gains an "Archived" filter tab.
9+
- `/api/og` generates 1200×630 Open Graph images; the share page emits dynamic OG/Twitter tags built from the clip's title, score, and thumbnail.
10+
- `robots.ts` now disallows every `(dashboard)` route including `/billing`, `/analytics`, and `/referral`; `sitemap.ts` covers the public share section.
11+
- Dependabot splits patch (weekly, auto-approved) from minor (monthly, manual), grouped by `@stellar/*`, `@sentry/*`, `@storybook/*`, and `@testing-library/*`; CI verifies the lockfile with `npm ci --dry-run`.

.github/dependabot.yml

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,107 @@
11
version: 2
22
updates:
3+
# Patch updates: weekly, grouped, low-risk enough to auto-approve.
34
- package-ecosystem: "npm"
45
directory: "/"
56
schedule:
67
interval: "weekly"
8+
day: "monday"
9+
open-pull-requests-limit: 5
10+
labels:
11+
- "dependencies"
12+
- "patch"
13+
commit-message:
14+
prefix: "chore(deps)"
715
groups:
8-
dependencies:
16+
stellar-patch:
17+
patterns:
18+
- "@stellar/*"
19+
- "stellar-sdk"
20+
update-types:
21+
- "patch"
22+
sentry-patch:
23+
patterns:
24+
- "@sentry/*"
25+
update-types:
26+
- "patch"
27+
storybook-patch:
28+
patterns:
29+
- "@storybook/*"
30+
- "storybook"
31+
update-types:
32+
- "patch"
33+
testing-patch:
34+
patterns:
35+
- "@testing-library/*"
36+
update-types:
37+
- "patch"
38+
all-patch:
39+
patterns:
40+
- "*"
41+
update-types:
42+
- "patch"
43+
ignore:
44+
# Minor and major land on the monthly schedule below, so this weekly
45+
# run stays patch-only.
46+
- dependency-name: "*"
47+
update-types:
48+
- "version-update:semver-minor"
49+
- "version-update:semver-major"
50+
51+
# Minor updates: monthly, grouped, always reviewed by a human.
52+
- package-ecosystem: "npm"
53+
directory: "/"
54+
schedule:
55+
interval: "monthly"
56+
open-pull-requests-limit: 5
57+
labels:
58+
- "dependencies"
59+
- "minor"
60+
- "needs-review"
61+
commit-message:
62+
prefix: "chore(deps)"
63+
groups:
64+
stellar-minor:
65+
patterns:
66+
- "@stellar/*"
67+
- "stellar-sdk"
68+
update-types:
69+
- "minor"
70+
sentry-minor:
71+
patterns:
72+
- "@sentry/*"
73+
update-types:
74+
- "minor"
75+
storybook-minor:
76+
patterns:
77+
- "@storybook/*"
78+
- "storybook"
79+
update-types:
80+
- "minor"
81+
testing-minor:
82+
patterns:
83+
- "@testing-library/*"
84+
update-types:
85+
- "minor"
86+
all-minor:
987
patterns:
1088
- "*"
89+
update-types:
90+
- "minor"
91+
ignore:
92+
# Majors stay manual — Next and React majors carry breaking changes this
93+
# app has to migrate deliberately.
94+
- dependency-name: "*"
95+
update-types:
96+
- "version-update:semver-patch"
97+
- "version-update:semver-major"
98+
99+
- package-ecosystem: "github-actions"
100+
directory: "/"
101+
schedule:
102+
interval: "monthly"
103+
labels:
104+
- "dependencies"
105+
- "github-actions"
106+
commit-message:
107+
prefix: "ci(deps)"

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,13 @@ jobs:
4242
node-version: lts/*
4343
cache: npm
4444
cache-dependency-path: package-lock.json
45+
# `npm ci --dry-run` fails when package.json and package-lock.json have
46+
# drifted, catching a dependency bump that never updated the lockfile.
47+
- name: Verify package-lock.json is in sync
48+
run: npm ci --dry-run
49+
4550
- name: Install dependencies
46-
run: npm install
51+
run: npm ci
4752
- name: Unit tests
4853
run: echo "E2E PR — unit coverage validated on sibling PRs."
4954
- name: Check for changeset
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Dependabot Auto-Approve
2+
3+
# Auto-approves patch-level Dependabot PRs. Minor and major updates are left
4+
# for manual review, matching the split in .github/dependabot.yml.
5+
#
6+
# `pull_request_target` is required so the token has write access on PRs from
7+
# Dependabot's fork. It is safe here only because this workflow never checks
8+
# out or executes PR code — it just reads metadata and approves.
9+
on: pull_request_target
10+
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
jobs:
16+
auto-approve:
17+
runs-on: ubuntu-latest
18+
if: github.actor == 'dependabot[bot]'
19+
steps:
20+
- name: Fetch Dependabot metadata
21+
id: metadata
22+
uses: dependabot/fetch-metadata@v2
23+
with:
24+
github-token: ${{ secrets.GITHUB_TOKEN }}
25+
26+
- name: Approve patch updates
27+
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
28+
run: gh pr review --approve "$PR_URL" --body "Auto-approved: patch-level dependency update."
29+
env:
30+
PR_URL: ${{ github.event.pull_request.html_url }}
31+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32+
33+
- name: Flag minor and major updates for review
34+
if: steps.metadata.outputs.update-type != 'version-update:semver-patch'
35+
run: |
36+
gh pr comment "$PR_URL" --body \
37+
"This is a **${{ steps.metadata.outputs.update-type }}** update and needs manual review before merging."
38+
env:
39+
PR_URL: ${{ github.event.pull_request.html_url }}
40+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.husky/pre-commit

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ npx lint-staged
88
# shallow clone) rather than failing the commit.
99
BASE_BRANCH="${CHANGESET_BASE_BRANCH:-main}"
1010
if git rev-parse --verify "origin/$BASE_BRANCH" >/dev/null 2>&1; then
11-
CHANGESET_COUNT=$(git diff --name-only "origin/$BASE_BRANCH"...HEAD 2>/dev/null | grep -c '^\.changeset/.*\.md$')
11+
# `grep -c` exits 1 when it counts zero matches, which aborts this hook under
12+
# husky's `set -e` — the exact case this reminder exists for. Fall back to 0
13+
# so the block stays advisory, as documented above.
14+
CHANGESET_COUNT=$(git diff --name-only "origin/$BASE_BRANCH"...HEAD 2>/dev/null | grep -c '^\.changeset/.*\.md$' || true)
15+
CHANGESET_COUNT=${CHANGESET_COUNT:-0}
1216
if [ "$CHANGESET_COUNT" -eq 0 ]; then
1317
echo ""
1418
echo "No changeset found for this branch yet."

app/(dashboard)/projects/page.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ export default function ProjectsPage() {
6565
const [loadingNextPage, setLoadingNextPage] = useState(false);
6666
const [isPosting, setIsPosting] = useState(false);
6767
const [postError, setPostError] = useState<string | null>(null);
68+
const [isDeleting, setIsDeleting] = useState(false);
69+
const [deleteError, setDeleteError] = useState<string | null>(null);
70+
const [isArchiving, setIsArchiving] = useState(false);
71+
const [archiveError, setArchiveError] = useState<string | null>(null);
6872

6973
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
7074
const [aiRecommendations, setAiRecommendations] = useState(false);
@@ -254,7 +258,7 @@ export default function ProjectsPage() {
254258
if (!res.ok) throw new Error(data.error || "Posting failed");
255259
if (Array.isArray(data.failed) && data.failed.length > 0) {
256260
setPostError(`${data.failed.length} post${data.failed.length > 1 ? "s" : ""} failed`);
257-
data.failed.forEach((f: any) => console.warn(f));
261+
data.failed.forEach((f: unknown) => console.warn(f));
258262
}
259263
if (Array.isArray(data.posted) && data.posted.length > 0) {
260264
showToast(`Posted ${data.posted.length} clip${data.posted.length > 1 ? "s" : ""} successfully`, "success");
@@ -268,6 +272,58 @@ export default function ProjectsPage() {
268272
}
269273
}, [showToast]);
270274

275+
const handleDelete = useCallback(async (clipIds: string[]) => {
276+
setIsDeleting(true);
277+
setDeleteError(null);
278+
try {
279+
const res = await fetch("/api/clips", {
280+
method: "DELETE",
281+
headers: { "Content-Type": "application/json" },
282+
body: JSON.stringify({ clipIds }),
283+
});
284+
const data = await res.json();
285+
if (!res.ok) throw new Error(data.error || "Delete failed");
286+
287+
const count = data?.data?.deletedCount ?? clipIds.length;
288+
showToast(`Deleted ${count} clip${count !== 1 ? "s" : ""}`, "success");
289+
setSelectedIds([]);
290+
// Deleted clips are filtered out server-side, so refetch rather than
291+
// trying to reconcile the list locally.
292+
await fetchClips(1);
293+
} catch (err) {
294+
const msg = err instanceof Error ? err.message : "Delete failed";
295+
setDeleteError(msg);
296+
showToast(msg, "error");
297+
} finally {
298+
setIsDeleting(false);
299+
}
300+
}, [fetchClips, setSelectedIds, showToast]);
301+
302+
const handleArchive = useCallback(async (clipIds: string[]) => {
303+
setIsArchiving(true);
304+
setArchiveError(null);
305+
try {
306+
const res = await fetch("/api/clips/archive", {
307+
method: "PATCH",
308+
headers: { "Content-Type": "application/json" },
309+
body: JSON.stringify({ clipIds }),
310+
});
311+
const data = await res.json();
312+
if (!res.ok) throw new Error(data.error || "Archive failed");
313+
314+
const count = data?.data?.archivedCount ?? clipIds.length;
315+
showToast(`Archived ${count} clip${count !== 1 ? "s" : ""}`, "success");
316+
setSelectedIds([]);
317+
await fetchClips(1);
318+
} catch (err) {
319+
const msg = err instanceof Error ? err.message : "Archive failed";
320+
setArchiveError(msg);
321+
showToast(msg, "error");
322+
} finally {
323+
setIsArchiving(false);
324+
}
325+
}, [fetchClips, setSelectedIds, showToast]);
326+
271327
return (
272328
<>
273329
{/* Mobile Filter Drawer Overlay */}
@@ -357,6 +413,12 @@ export default function ProjectsPage() {
357413
onPost={handlePost}
358414
isPosting={isPosting}
359415
postError={postError}
416+
onDelete={handleDelete}
417+
isDeleting={isDeleting}
418+
deleteError={deleteError}
419+
onArchive={handleArchive}
420+
isArchiving={isArchiving}
421+
archiveError={archiveError}
360422
/>
361423
</div>
362424
</div>

app/api/clips/archive/route.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/app/lib/auth";
3+
import { clipsStore } from "../clipsStore";
4+
import type { ApiResponse } from "../../types";
5+
import { bulkClipIdsBodySchema } from "../../schemas/index";
6+
7+
/** Shared auth + validation + ownership path for both handlers below. */
8+
async function resolveRequest(request: NextRequest): Promise<
9+
| { ok: true; userId: string; clipIds: string[] }
10+
| { ok: false; response: NextResponse }
11+
> {
12+
const session = await auth();
13+
if (!session?.user?.id) {
14+
return {
15+
ok: false,
16+
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
17+
};
18+
}
19+
20+
let payload: unknown;
21+
try {
22+
payload = await request.json();
23+
} catch {
24+
return {
25+
ok: false,
26+
response: NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }),
27+
};
28+
}
29+
30+
const parsed = bulkClipIdsBodySchema.safeParse(payload);
31+
if (!parsed.success) {
32+
return {
33+
ok: false,
34+
response: NextResponse.json(
35+
{ error: "Validation failed", issues: parsed.error.issues },
36+
{ status: 400 },
37+
),
38+
};
39+
}
40+
41+
// Seed the user's clips so ownership resolves on a first-time request.
42+
clipsStore.getClipsForUser(session.user.id);
43+
44+
const unowned = clipsStore.findUnownedClipIds(
45+
session.user.id,
46+
parsed.data.clipIds,
47+
);
48+
if (unowned.length > 0) {
49+
return {
50+
ok: false,
51+
response: NextResponse.json(
52+
{ error: "One or more clips do not belong to you" },
53+
{ status: 403 },
54+
),
55+
};
56+
}
57+
58+
return { ok: true, userId: session.user.id, clipIds: parsed.data.clipIds };
59+
}
60+
61+
/**
62+
* PATCH /api/clips/archive
63+
* Body: { clipIds: string[] }
64+
*
65+
* Stamps `archivedAt`, moving clips out of the default library and into the
66+
* Archived tab. Non-destructive — DELETE /api/clips is the delete path.
67+
*/
68+
export async function PATCH(request: NextRequest) {
69+
const resolved = await resolveRequest(request);
70+
if (!resolved.ok) return resolved.response;
71+
72+
const archivedCount = clipsStore.archiveClips(resolved.userId, resolved.clipIds);
73+
74+
const body: ApiResponse<{ success: boolean; archivedCount: number }> = {
75+
data: { success: true, archivedCount },
76+
error: null,
77+
};
78+
79+
return NextResponse.json(body);
80+
}
81+
82+
/**
83+
* DELETE /api/clips/archive
84+
* Body: { clipIds: string[] }
85+
*
86+
* Unarchives — clears `archivedAt` and returns clips to the main library.
87+
* This does not delete anything.
88+
*/
89+
export async function DELETE(request: NextRequest) {
90+
const resolved = await resolveRequest(request);
91+
if (!resolved.ok) return resolved.response;
92+
93+
const restoredCount = clipsStore.unarchiveClips(resolved.userId, resolved.clipIds);
94+
95+
const body: ApiResponse<{ success: boolean; restoredCount: number }> = {
96+
data: { success: true, restoredCount },
97+
error: null,
98+
};
99+
100+
return NextResponse.json(body);
101+
}

0 commit comments

Comments
 (0)