feat: add recording sharing and community features

Backend Changes:
- Added original_recording_id field to sexy_recordings table to track duplicates
- Added indexes for original_recording_id and public fields
- Implemented /sexy/community-recordings endpoint to list public shared recordings
- Implemented /sexy/recordings/:id/duplicate endpoint to duplicate community recordings
- Community recordings filtered by status=published AND public=true
- Duplication creates a private draft copy for the current user

Frontend Changes:
- Added leaderboard and profile quick links to play view header
- Added navigation buttons for better UX on play page
- Added translations: my_profile, anonymous, load_more

Database Schema:
- ALTER TABLE sexy_recordings ADD COLUMN original_recording_id uuid
- Created foreign key and indexes for efficient queries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Valknar XXX
2025-10-28 14:32:39 +01:00
parent 9002eb768b
commit b883867b15
3 changed files with 138 additions and 1 deletions

View File

@@ -929,5 +929,119 @@ export default {
res.status(500).json({ error: error.message || "Failed to update play" });
}
});
// GET /sexy/community-recordings - List community shared recordings
router.get("/community-recordings", async (req, res) => {
try {
const limit = parseInt(req.query.limit as string) || 50;
const offset = parseInt(req.query.offset as string) || 0;
const recordingsService = new ItemsService("sexy_recordings", {
schema: await getSchema(),
accountability: null, // Public endpoint, no auth required
knex: database,
});
const recordings = await recordingsService.readByQuery({
filter: {
status: { _eq: "published" },
public: { _eq: true },
},
fields: [
"id",
"title",
"description",
"slug",
"duration",
"tags",
"date_created",
"user_created.id",
"user_created.first_name",
"user_created.last_name",
"user_created.avatar",
],
limit,
offset,
sort: ["-date_created"],
});
res.json({ data: recordings });
} catch (error: any) {
console.error("List community recordings error:", error);
res.status(500).json({ error: error.message || "Failed to list community recordings" });
}
});
// POST /sexy/recordings/:id/duplicate - Duplicate a community recording to current user
router.post("/recordings/:id/duplicate", async (req, res) => {
try {
const accountability = req.accountability;
if (!accountability?.user) {
return res.status(401).json({ error: "Authentication required" });
}
const recordingId = req.params.id;
// Fetch the original recording
const schema = await getSchema();
const recordingsService = new ItemsService("sexy_recordings", {
schema,
accountability: null, // Need to read any public recording
knex: database,
});
const originalRecording = await recordingsService.readOne(recordingId, {
fields: [
"id",
"title",
"description",
"duration",
"events",
"device_info",
"tags",
"status",
"public",
],
});
// Verify it's a published, public recording
if (originalRecording.status !== "published" || !originalRecording.public) {
return res.status(403).json({ error: "Recording is not publicly shared" });
}
// Create duplicate with current user's accountability
const userRecordingsService = new ItemsService("sexy_recordings", {
schema,
accountability,
knex: database,
});
// Generate unique slug
const baseSlug = originalRecording.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const timestamp = Date.now();
const slug = `${baseSlug}-copy-${timestamp}`;
const duplicatedRecording = await userRecordingsService.createOne({
title: `${originalRecording.title} (Copy)`,
description: originalRecording.description,
slug,
duration: originalRecording.duration,
events: originalRecording.events,
device_info: originalRecording.device_info,
tags: originalRecording.tags || [],
status: "draft",
public: false,
original_recording_id: recordingId,
});
res.status(201).json({ data: duplicatedRecording });
} catch (error: any) {
console.error("Duplicate recording error:", error);
res.status(500).json({ error: error.message || "Failed to duplicate recording" });
}
});
},
};

View File

@@ -20,6 +20,9 @@ export default {
open: "Open",
yes: "Yes",
no: "No",
my_profile: "My Profile",
anonymous: "Anonymous",
load_more: "Load More",
},
header: {
home: "Home",

View File

@@ -437,9 +437,29 @@ onMount(() => {
>
{$_("play.title")}
</h1>
<p class="text-lg text-muted-foreground mb-10">
<p class="text-lg text-muted-foreground mb-6">
{$_("play.description")}
</p>
<div class="flex justify-center gap-3 mb-10">
<Button
variant="outline"
size="sm"
href="/leaderboard"
class="border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--trophy-line] w-4 h-4 mr-2"></span>
{$_("gamification.leaderboard")}
</Button>
<Button
variant="outline"
size="sm"
href="/me"
class="border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--user-line] w-4 h-4 mr-2"></span>
{$_("common.my_profile")}
</Button>
</div>
<div class="flex justify-center gap-4 items-center">
<Button
size="lg"