Skip to content

Commit b91a8ae

Browse files
lwinmoepaingclaude
andcommitted
✨ feat: add profile editor page with live preview and MDX download
Add /profile/editor with split-pane layout: form-based editor (name, description, image, tags, markdown body) on the left and live preview with actual ProfileCardItem on the right. Includes MDX file download, raw output toggle, and react-markdown for body rendering. Added Editor links to Navbar and Footer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 720207f commit b91a8ae

12 files changed

Lines changed: 1050 additions & 14 deletions

File tree

bun.lockb

1.04 KB
Binary file not shown.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"react": "19.2.4",
5151
"react-dom": "19.2.4",
5252
"react-icons": "^4.11.0",
53+
"react-markdown": "^10.1.0",
5354
"rehype-autolink-headings": "^7.0.0",
5455
"rehype-pretty-code": "^0.10.1",
5556
"rehype-slug": "^6.0.0",

src/app/profile/[slug]/page.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { FC } from "react";
88

99
const getProfileFromParam = async (slug: string) => {
1010
const profileDetail = allProfiles.find(
11-
(profile) => profile.slugAsParams === slug
11+
(profile) => profile.slugAsParams === slug,
1212
);
1313

1414
if (!profileDetail) {
@@ -21,9 +21,7 @@ const getProfileFromParam = async (slug: string) => {
2121
export async function generateMetadata(props: TPProfileDetailPageProps) {
2222
const params = await props.params;
2323

24-
const {
25-
slug
26-
} = params;
24+
const { slug } = params;
2725

2826
const profile = await getProfileFromParam(slug);
2927

@@ -50,22 +48,22 @@ type TPProfileDetailPageProps = {
5048
}>;
5149
};
5250

53-
const PProfileDetailPage: FC<TPProfileDetailPageProps> = async props => {
51+
const PProfileDetailPage: FC<TPProfileDetailPageProps> = async (props) => {
5452
const params = await props.params;
5553

56-
const {
57-
slug
58-
} = params;
54+
const { slug } = params;
5955

6056
const profile = await getProfileFromParam(slug);
6157

6258
return (
6359
<PageTransitionWrapper>
6460
<Container>
65-
<Mdx
66-
code={profile.body.code}
67-
extraText={`${profile.name} | ${profile.description}`}
68-
/>
61+
<section className="py-16">
62+
<Mdx
63+
code={profile.body.code}
64+
extraText={`${profile.name} | ${profile.description}`}
65+
/>
66+
</section>
6967

7068
<SpacingDivider size="lg" />
7169
</Container>
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
"use client";
2+
3+
import { cn } from "@/utils";
4+
import Container from "@/components/Common/Container/Container";
5+
import EditorPane from "@/components/ProfileEditor/EditorPane";
6+
import PreviewPane from "@/components/ProfileEditor/PreviewPane";
7+
import { useProfileEditor } from "@/hooks/profile/useProfileEditor";
8+
import { motion, useInView } from "framer-motion";
9+
import { useRef } from "react";
10+
import {
11+
PenTool,
12+
Download,
13+
RotateCcw,
14+
Sparkles,
15+
} from "lucide-react";
16+
17+
/* ── Floating ambient orbs ── */
18+
const FloatingOrb = ({
19+
size,
20+
color,
21+
x,
22+
y,
23+
delay,
24+
duration,
25+
}: {
26+
size: number;
27+
color: string;
28+
x: string;
29+
y: string;
30+
delay: number;
31+
duration: number;
32+
}) => (
33+
<motion.div
34+
className="absolute rounded-full pointer-events-none"
35+
style={{
36+
width: size,
37+
height: size,
38+
left: x,
39+
top: y,
40+
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
41+
filter: "blur(50px)",
42+
}}
43+
animate={{
44+
y: [0, -18, 10, -12, 0],
45+
x: [0, 12, -8, 10, 0],
46+
scale: [1, 1.12, 0.92, 1.08, 1],
47+
opacity: [0.2, 0.35, 0.15, 0.3, 0.2],
48+
}}
49+
transition={{
50+
duration,
51+
delay,
52+
repeat: Infinity,
53+
ease: "easeInOut",
54+
}}
55+
/>
56+
);
57+
58+
/* ── Grid decoration ── */
59+
const GridDecoration = () => (
60+
<div className="absolute inset-0 pointer-events-none opacity-[0.03]">
61+
<div
62+
className="absolute inset-0"
63+
style={{
64+
backgroundImage: `
65+
linear-gradient(rgba(167, 139, 250, 0.5) 1px, transparent 1px),
66+
linear-gradient(90deg, rgba(167, 139, 250, 0.5) 1px, transparent 1px)
67+
`,
68+
backgroundSize: "60px 60px",
69+
}}
70+
/>
71+
</div>
72+
);
73+
74+
/* ── Floating sparkle particles ── */
75+
const FloatingSparkle = ({
76+
delay,
77+
x,
78+
y,
79+
}: {
80+
delay: number;
81+
x: string;
82+
y: string;
83+
}) => (
84+
<motion.div
85+
className="absolute pointer-events-none"
86+
style={{ left: x, top: y }}
87+
animate={{
88+
opacity: [0, 0.7, 0],
89+
scale: [0.5, 1.2, 0.5],
90+
y: [0, -16, 0],
91+
}}
92+
transition={{
93+
duration: 3.5,
94+
delay,
95+
repeat: Infinity,
96+
ease: "easeInOut",
97+
}}
98+
>
99+
<Sparkles className="w-3 h-3 text-prism-violet" />
100+
</motion.div>
101+
);
102+
103+
/* ── Main Profile Editor Client ── */
104+
const ProfileEditorClient = () => {
105+
const heroRef = useRef(null);
106+
const heroInView = useInView(heroRef, { amount: 0.3, once: true });
107+
108+
const editor = useProfileEditor();
109+
110+
return (
111+
<div className="relative min-h-[60vh]">
112+
{/* Background decorations */}
113+
<div className="absolute inset-0 pointer-events-none">
114+
<GridDecoration />
115+
<FloatingOrb
116+
size={200}
117+
color="#a78bfa"
118+
x="-5%"
119+
y="10%"
120+
delay={0}
121+
duration={9}
122+
/>
123+
<FloatingOrb
124+
size={180}
125+
color="#22d3ee"
126+
x="85%"
127+
y="35%"
128+
delay={2}
129+
duration={10}
130+
/>
131+
<FloatingOrb
132+
size={140}
133+
color="#fb7185"
134+
x="50%"
135+
y="75%"
136+
delay={3.5}
137+
duration={8}
138+
/>
139+
<FloatingSparkle delay={0.5} x="8%" y="15%" />
140+
<FloatingSparkle delay={2.5} x="92%" y="20%" />
141+
<FloatingSparkle delay={4} x="40%" y="8%" />
142+
</div>
143+
144+
{/* Hero header */}
145+
<div ref={heroRef} className="relative z-10 pt-8 pb-6 md:pt-12 md:pb-10">
146+
<Container withPadding>
147+
{/* Section label */}
148+
<motion.div
149+
className="flex items-center gap-3 mb-6"
150+
initial={{ opacity: 0, x: -20 }}
151+
animate={
152+
heroInView ? { opacity: 1, x: 0 } : { opacity: 0, x: -20 }
153+
}
154+
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
155+
>
156+
<motion.div
157+
className="flex items-center justify-center w-8 h-8 rounded-lg"
158+
style={{
159+
background: "linear-gradient(135deg, #a78bfa12, #22d3ee08)",
160+
border: "1px solid rgba(167,139,250,0.15)",
161+
}}
162+
whileHover={{ scale: 1.1, rotate: 5 }}
163+
transition={{ type: "spring", stiffness: 400 }}
164+
>
165+
<PenTool className="w-4 h-4 text-prism-violet" />
166+
</motion.div>
167+
<span className="font-mono text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
168+
Profile Editor
169+
</span>
170+
</motion.div>
171+
172+
{/* Title */}
173+
<motion.div
174+
className="relative overflow-hidden mb-4"
175+
initial={{ opacity: 0 }}
176+
animate={heroInView ? { opacity: 1 } : { opacity: 0 }}
177+
transition={{ duration: 0.1, delay: 0.1 }}
178+
>
179+
<motion.div
180+
className="absolute inset-0 pointer-events-none"
181+
style={{
182+
background:
183+
"linear-gradient(90deg, transparent 0%, rgba(167,139,250,0.1) 50%, transparent 100%)",
184+
}}
185+
initial={{ x: "-100%" }}
186+
animate={heroInView ? { x: "200%" } : { x: "-100%" }}
187+
transition={{ duration: 1.2, delay: 0.6, ease: "easeInOut" }}
188+
/>
189+
<motion.h1
190+
className="font-display font-bold text-4xl sm:text-5xl md:text-6xl bg-gradient-to-r from-prism-violet via-prism-cyan to-prism-rose bg-clip-text text-transparent leading-[1.15]"
191+
initial={{ y: 50, opacity: 0, filter: "blur(6px)" }}
192+
animate={
193+
heroInView
194+
? { y: 0, opacity: 1, filter: "blur(0px)" }
195+
: { y: 50, opacity: 0, filter: "blur(6px)" }
196+
}
197+
transition={{
198+
duration: 0.7,
199+
delay: 0.15,
200+
ease: [0.22, 1, 0.36, 1],
201+
}}
202+
>
203+
Create Your Profile
204+
</motion.h1>
205+
</motion.div>
206+
207+
{/* Subtitle */}
208+
<motion.p
209+
className="font-body text-base text-zinc-500 max-w-lg leading-relaxed"
210+
initial={{ opacity: 0, y: 15 }}
211+
animate={
212+
heroInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 15 }
213+
}
214+
transition={{ duration: 0.6, delay: 0.35, ease: "easeOut" }}
215+
>
216+
Fill in your details, preview your profile, and{" "}
217+
<span className="text-prism-cyan">download</span> the{" "}
218+
<span className="text-prism-violet">.mdx</span> file to submit as a
219+
pull request.
220+
</motion.p>
221+
222+
{/* Decorative divider */}
223+
<motion.div
224+
className="mt-8 h-[1px]"
225+
style={{
226+
background:
227+
"linear-gradient(90deg, #a78bfa15, #22d3ee25, #fb718515, transparent 80%)",
228+
}}
229+
initial={{ scaleX: 0, originX: 0 }}
230+
animate={heroInView ? { scaleX: 1 } : { scaleX: 0 }}
231+
transition={{ duration: 1, delay: 0.6, ease: "easeOut" }}
232+
/>
233+
</Container>
234+
</div>
235+
236+
{/* Editor section */}
237+
<div className="relative z-10 pb-16">
238+
<Container withPadding>
239+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
240+
<EditorPane
241+
name={editor.name}
242+
description={editor.description}
243+
image={editor.image}
244+
tags={editor.tags}
245+
body={editor.body}
246+
setName={editor.setName}
247+
setDescription={editor.setDescription}
248+
setImage={editor.setImage}
249+
addTag={editor.addTag}
250+
removeTag={editor.removeTag}
251+
setBody={editor.setBody}
252+
/>
253+
<PreviewPane
254+
name={editor.name}
255+
description={editor.description}
256+
image={editor.image}
257+
tags={editor.tags}
258+
body={editor.body}
259+
mdxOutput={editor.mdxOutput}
260+
/>
261+
</div>
262+
263+
{/* Action buttons */}
264+
<motion.div
265+
className="mt-6 flex flex-wrap items-center gap-4"
266+
initial={{ opacity: 0, y: 15 }}
267+
animate={{ opacity: 1, y: 0 }}
268+
transition={{ duration: 0.5, delay: 0.5, ease: "easeOut" }}
269+
>
270+
<button
271+
type="button"
272+
onClick={editor.handleDownload}
273+
disabled={!editor.isValid}
274+
className={cn(
275+
"inline-flex items-center gap-2 px-6 py-2.5 rounded-full",
276+
"font-display text-sm font-bold tracking-tight",
277+
"bg-gradient-to-r from-prism-violet via-prism-cyan to-prism-rose text-obsidian",
278+
"hover:shadow-[0_0_20px_rgba(167,139,250,0.3)]",
279+
"active:scale-95 transition-all duration-200",
280+
"disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:shadow-none"
281+
)}
282+
>
283+
<Download className="w-4 h-4" />
284+
Download .mdx
285+
</button>
286+
287+
<button
288+
type="button"
289+
onClick={editor.handleReset}
290+
className={cn(
291+
"inline-flex items-center gap-2 px-5 py-2.5 rounded-full",
292+
"font-display text-sm font-bold tracking-tight",
293+
"bg-white/[0.04] text-zinc-400 border border-white/[0.08]",
294+
"hover:bg-white/[0.08] hover:text-zinc-200 hover:border-white/[0.12]",
295+
"active:scale-95 transition-all duration-200"
296+
)}
297+
>
298+
<RotateCcw className="w-4 h-4" />
299+
Reset
300+
</button>
301+
</motion.div>
302+
</Container>
303+
</div>
304+
</div>
305+
);
306+
};
307+
308+
export default ProfileEditorClient;

src/app/profile/editor/page.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import PageTransitionWrapper from "@/components/Animate/PageTransitionWrapper/PageTransitionWrapper";
2+
import APP_CONFIG from "@/config/config";
3+
import { Metadata } from "next";
4+
import ProfileEditorClient from "./ProfileEditorClient";
5+
6+
export const metadata: Metadata = {
7+
title: `Profile Editor | ${APP_CONFIG.title}`,
8+
description:
9+
"Create your developer profile for the Myanmar Software Engineers community.",
10+
openGraph: {
11+
title: `Profile Editor | ${APP_CONFIG.title}`,
12+
description:
13+
"Create your developer profile for the Myanmar Software Engineers community.",
14+
images: "https://mmswe.com/images/mmswe-seo.png",
15+
},
16+
};
17+
18+
const ProfileEditorPage = () => {
19+
return (
20+
<PageTransitionWrapper>
21+
<ProfileEditorClient />
22+
</PageTransitionWrapper>
23+
);
24+
};
25+
26+
export default ProfileEditorPage;

0 commit comments

Comments
 (0)