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:
@@ -929,5 +929,119 @@ export default {
|
|||||||
res.status(500).json({ error: error.message || "Failed to update play" });
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default {
|
|||||||
open: "Open",
|
open: "Open",
|
||||||
yes: "Yes",
|
yes: "Yes",
|
||||||
no: "No",
|
no: "No",
|
||||||
|
my_profile: "My Profile",
|
||||||
|
anonymous: "Anonymous",
|
||||||
|
load_more: "Load More",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
home: "Home",
|
home: "Home",
|
||||||
|
|||||||
@@ -437,9 +437,29 @@ onMount(() => {
|
|||||||
>
|
>
|
||||||
{$_("play.title")}
|
{$_("play.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-muted-foreground mb-10">
|
<p class="text-lg text-muted-foreground mb-6">
|
||||||
{$_("play.description")}
|
{$_("play.description")}
|
||||||
</p>
|
</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">
|
<div class="flex justify-center gap-4 items-center">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
Reference in New Issue
Block a user