Merge pull request #2483 from y8a5y/main

[Feature] Permalink to share/download resume json data
This commit is contained in:
Amruth Pillai
2025-12-23 03:12:39 +01:00
committed by GitHub
4 changed files with 93 additions and 24 deletions
+10
View File
@@ -82,4 +82,14 @@ git commit -m "fix(homepage): fix typo on homepage in the faq section"
It helps to be as descriptive as possible in commit messages so that users can be aware of the changes made by you.
Then, you can run the basic checks, as to make sure the CI has the most chance of being successful:
```
pnpm run lint
pnpm run test
pnpm run build
```
Finally, create a pull request to merge the changes on your forked repository to the original repository hosted on AmruthPillai/Reactive-Resume. I can take a look at the changes you've made when I have the time and have it merged onto the app.
@@ -4,6 +4,7 @@ import { Button, Input, Label, Switch, Tooltip } from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useToast } from "@/client/hooks/use-toast";
import { axios } from "@/client/libs/axios"
import { useUser } from "@/client/services/user";
import { useResumeStore } from "@/client/stores/resume";
@@ -20,6 +21,7 @@ export const SharingSection = () => {
// Constants
const url = `${window.location.origin}/${username}/${slug}`;
const rawJsonUrl = `${window.location.origin}${axios.defaults.baseURL}/resume/public/${username}/${slug}/stealthily`;
const onCopy = async () => {
await navigator.clipboard.writeText(url);
@@ -31,6 +33,16 @@ export const SharingSection = () => {
});
};
const onRawJsonCopy = async () => {
await navigator.clipboard.writeText(rawJsonUrl);
toast({
variant: "success",
title: t`A link has been copied to your clipboard.`,
description: t`Anyone with this link can view and download the json data of the resume. This link will not count towards the resume view count.`,
});
};
return (
<section id="sharing" className="grid gap-y-6">
<header className="flex items-center justify-between">
@@ -65,21 +77,50 @@ export const SharingSection = () => {
{isPublic && (
<motion.div
layout
className="space-y-1.5"
className="space-y-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Label htmlFor="resume-url">{t`URL`}</Label>
<div className="space-y-1.5">
<Label htmlFor="resume-url">{t`URL`}</Label>
<div className="flex gap-x-1.5">
<Input readOnly id="resume-url" value={url} className="flex-1" />
<div className="flex gap-x-1.5">
<Input
readOnly
id="resume-url"
value={url}
className="flex-1 caret-transparent"
/>
<Tooltip content={t`Copy to Clipboard`}>
<Button size="icon" variant="ghost" onClick={onCopy}>
<CopySimpleIcon />
</Button>
</Tooltip>
<Tooltip content={t`Copy to Clipboard`}>
<Button size="icon" variant="ghost" onClick={onCopy}>
<CopySimpleIcon />
</Button>
</Tooltip>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="resume-raw-json-url" className="space-y-1">
<p>{t`Raw JSON URL`}</p>
<p className="text-xs opacity-60">{t`For advanced users! Does not count towards resume statistics.`}</p>
</Label>
<div className="flex gap-x-1.5">
<Input
readOnly
id="resume-raw-json-url"
value={rawJsonUrl}
className="flex-1 caret-transparent"
/>
<Tooltip content={t`Copy to Clipboard`}>
<Button size="icon" variant="ghost" onClick={onRawJsonCopy}>
<CopySimpleIcon />
</Button>
</Tooltip>
</div>
</div>
</motion.div>
)}
+24 -4
View File
@@ -20,7 +20,7 @@ import {
ResumeDto,
UpdateResumeDto,
} from "@reactive-resume/dto";
import { ResumeData, resumeDataSchema } from "@reactive-resume/schema";
import { resumeDataSchema } from "@reactive-resume/schema";
import { ErrorMessage } from "@reactive-resume/utils";
import set from "lodash.set";
import { zodToJsonSchema } from "zod-to-json-schema";
@@ -92,6 +92,19 @@ export class ResumeController {
return this.resumeService.findOneStatistics(id);
}
@Get("/public/:username/:slug/stealthily")
@UseGuards(OptionalGuard)
async findOneByUsernameSlugStealthily(
@Param("username") username: string,
@Param("slug") slug: string,
) {
const resume = await this.resumeService.findOneByUsernameSlug(username, slug);
redactPrivateNotes(resume as ResumeDto);
return resume;
}
@Get("/public/:username/:slug")
@UseGuards(OptionalGuard)
async findOneByUsernameSlug(
@@ -99,10 +112,13 @@ export class ResumeController {
@Param("slug") slug: string,
@User("id") userId: string,
) {
const resume = await this.resumeService.findOneByUsernameSlug(username, slug, userId);
const resume = await this.resumeService.findOneByUsernameSlug(username, slug);
// Hide private notes from public resume API responses
set(resume.data as ResumeData, "metadata.notes", undefined);
redactPrivateNotes(resume as ResumeDto);
if (!userId) {
await this.resumeService.incrementViewCountForOne(resume.id);
}
return resume;
}
@@ -155,3 +171,7 @@ export class ResumeController {
}
}
}
function redactPrivateNotes(resume: ResumeDto) {
set(resume.data, "metadata.notes", undefined);
}
+9 -11
View File
@@ -84,21 +84,19 @@ export class ResumeService {
};
}
async findOneByUsernameSlug(username: string, slug: string, userId?: string) {
const resume = await this.prisma.resume.findFirstOrThrow({
async findOneByUsernameSlug(username: string, slug: string) {
return await this.prisma.resume.findFirstOrThrow({
where: { user: { username }, slug, visibility: "public" },
});
}
async incrementViewCountForOne(resumeId: string) {
// Update statistics: increment the number of views by 1
if (!userId) {
await this.prisma.statistics.upsert({
where: { resumeId: resume.id },
create: { views: 1, downloads: 0, resumeId: resume.id },
update: { views: { increment: 1 } },
});
}
return resume;
await this.prisma.statistics.upsert({
where: { resumeId },
create: { views: 1, downloads: 0, resumeId },
update: { views: { increment: 1 } },
});
}
async update(userId: string, id: string, updateResumeDto: UpdateResumeDto) {