first commit
This commit is contained in:
59
src/pages/About.jsx
Normal file
59
src/pages/About.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import AboutCard from "../components/AboutCard";
|
||||
import AboutDetails from "../components/AboutDetails";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
const SITE_URL = "https://priscy-orcin.vercel.app";
|
||||
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
|
||||
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
|
||||
const TITLE = "About — Priscy Designs";
|
||||
const DESCRIPTION =
|
||||
"Explore UI/UX, full-stack development, branding, and product design projects.";
|
||||
|
||||
export default function About() {
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: TITLE,
|
||||
url: `${SITE_URL}/about`,
|
||||
description: DESCRIPTION,
|
||||
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: LOGO_URL },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
canonical={SITE_URL}
|
||||
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
|
||||
twitter={{ image: COVER_URL }}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
<section className="lg:p-0 flex flex-col gap-5">
|
||||
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Left column: sticky card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="sticky top-6">
|
||||
{" "}
|
||||
{/* adjust top-6 for offset under your navbar */}
|
||||
<AboutCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right columns: long scrolling content */}
|
||||
<div className="lg:col-span-2">
|
||||
<AboutDetails />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
136
src/pages/Blog.jsx
Normal file
136
src/pages/Blog.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import posts from "../data/blog.json";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
const SITE_URL = "https://priscy-orcin.vercel.app";
|
||||
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
|
||||
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
|
||||
const TITLE = "Blog — Priscy Designs";
|
||||
const DESCRIPTION =
|
||||
"Explore UI/UX, full-stack development, branding, and product design projects.";
|
||||
|
||||
const PER_PAGE = 6;
|
||||
|
||||
export default function Blog() {
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: TITLE,
|
||||
url: `${SITE_URL}/blog`,
|
||||
description: DESCRIPTION,
|
||||
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: LOGO_URL },
|
||||
},
|
||||
};
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const pageFromUrl = Number(params.get("page")) || 1;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(posts.length / PER_PAGE));
|
||||
const page = Math.min(Math.max(1, pageFromUrl), totalPages);
|
||||
|
||||
// Keep URL clean & valid if someone types ?page=999 or ?page=abc
|
||||
useEffect(() => {
|
||||
if (page !== pageFromUrl)
|
||||
setParams({ page: String(page) }, { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, pageFromUrl]);
|
||||
|
||||
// Slice the posts for this page
|
||||
const pagePosts = useMemo(() => {
|
||||
const start = (page - 1) * PER_PAGE;
|
||||
return posts.slice(start, start + PER_PAGE);
|
||||
}, [page]);
|
||||
|
||||
// Scroll to top on page change
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [page]);
|
||||
|
||||
const goto = (p) => setParams({ page: String(p) });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
canonical={SITE_URL}
|
||||
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
|
||||
twitter={{ image: COVER_URL }}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
<div className="mt-14 lg:mt-16 bg-[var(--card)] text-[var(--text)] rounded-2xl p-3">
|
||||
<h1 className="text-3xl py-3">Recent Posts</h1>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pagePosts.map((p) => (
|
||||
<article
|
||||
key={p.id}
|
||||
className="rounded-xl border border-gray-200 dark:border-neutral-800 overflow-hidden flex flex-col justify-between"
|
||||
>
|
||||
<img
|
||||
src={p.coverImage}
|
||||
alt={p.title}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
{p.category} • {new Date(p.date).toLocaleDateString()}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold mt-1">{p.title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-3 mt-1">
|
||||
{p.excerpt}
|
||||
</p>
|
||||
<Link
|
||||
to={`/blog/${p.slug}`}
|
||||
className="inline-block mt-3 text-blue-600 hover:underline"
|
||||
>
|
||||
See More →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<nav
|
||||
className="mt-8 flex items-center justify-center gap-2"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
onClick={() => goto(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{/* Page numbers (simple: show all; you can window this if pages get big) */}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => goto(p)}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
className={`px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800
|
||||
${p === page ? "bg-blue-600 text-white border-blue-600" : ""}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => goto(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
335
src/pages/BlogDetail.jsx
Normal file
335
src/pages/BlogDetail.jsx
Normal file
@@ -0,0 +1,335 @@
|
||||
// src/pages/BlogDetail.jsx
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import posts from "../data/blog.json";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
function seededRandom(seed) {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return () => {
|
||||
h += 0x6d2b79f5;
|
||||
let t = Math.imul(h ^ (h >>> 15), 1 | h);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export default function BlogDetail() {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// find post + index (always called)
|
||||
const { post, index } = useMemo(() => {
|
||||
const i = posts.findIndex((p) => p.slug === slug);
|
||||
return i !== -1 ? { post: posts[i], index: i } : { post: null, index: -1 };
|
||||
}, [slug]);
|
||||
|
||||
// SEO values
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://your-domain.com";
|
||||
const canonical = post
|
||||
? `${origin}/blog/${post.slug}`
|
||||
: `${origin}/blog/not-found`;
|
||||
const title = post
|
||||
? `${post.title} — Priscy Designs`
|
||||
: "Post not found — Priscy Designs";
|
||||
const description = post
|
||||
? (post.excerpt || post.body || "").toString().slice(0, 160)
|
||||
: "The requested blog post could not be found.";
|
||||
const cover = post?.coverImage || "/og-cover.jpg";
|
||||
const logo = "/logo.png";
|
||||
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: post?.title || "Post not found",
|
||||
description: description,
|
||||
image: cover,
|
||||
url: canonical,
|
||||
datePublished: post?.date,
|
||||
author: post?.author ? { "@type": "Person", name: post.author } : undefined,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: logo },
|
||||
},
|
||||
};
|
||||
|
||||
// related (always called)
|
||||
const related = useMemo(() => {
|
||||
if (!post) return [];
|
||||
const pool = posts.filter((p) => p !== post);
|
||||
const rng = seededRandom(post.slug);
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
return pool.slice(0, 4);
|
||||
}, [post]);
|
||||
|
||||
// prev/next (compute early)
|
||||
const prev =
|
||||
index === -1 ? null : posts[(index - 1 + posts.length) % posts.length];
|
||||
const next = index === -1 ? null : posts[(index + 1) % posts.length];
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={title}
|
||||
description={description}
|
||||
canonical={canonical}
|
||||
og={{
|
||||
url: canonical,
|
||||
image: cover,
|
||||
siteName: "Priscy Designs",
|
||||
title,
|
||||
description,
|
||||
}}
|
||||
twitter={{
|
||||
card: "summary_large_image",
|
||||
image: cover,
|
||||
title,
|
||||
description,
|
||||
}}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
<main className="py-12" role="main">
|
||||
<h1 className="text-2xl font-semibold mb-2 text-center">
|
||||
Post not found
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 mb-6">
|
||||
We couldn’t find a blog post at this URL.
|
||||
</p>
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
← Go Back
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const toSlug = (p) => `/blog/${p.slug}`;
|
||||
const date = new Date(post.date);
|
||||
const dateStr = date.toLocaleDateString();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={title}
|
||||
description={description}
|
||||
canonical={canonical}
|
||||
og={{
|
||||
url: canonical,
|
||||
image: cover,
|
||||
siteName: "Priscy Designs",
|
||||
title,
|
||||
description,
|
||||
}}
|
||||
twitter={{
|
||||
card: "summary_large_image",
|
||||
image: cover,
|
||||
title,
|
||||
description,
|
||||
}}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
|
||||
<main
|
||||
id="main"
|
||||
role="main"
|
||||
className="mt-14 lg:mt-16"
|
||||
aria-labelledby="post-title"
|
||||
>
|
||||
<section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,28%)]">
|
||||
{/* Main article */}
|
||||
<article
|
||||
className="space-y-6 bg-[var(--card)] text-[var(--text)] rounded-2xl p-3 h-fit"
|
||||
itemScope
|
||||
itemType="https://schema.org/BlogPosting"
|
||||
aria-labelledby="post-title"
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<li>
|
||||
<Link to="/blog" className="hover:underline">
|
||||
Blog
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li
|
||||
aria-current="page"
|
||||
className="text-gray-600 dark:text-gray-300 truncate max-w-[60ch]"
|
||||
>
|
||||
{post.title}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<figure className="rounded-2xl overflow-hidden border border-gray-200 dark:border-neutral-800">
|
||||
<img
|
||||
src={cover}
|
||||
alt={post.title}
|
||||
className="w-full h-96 object-cover"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
{post.excerpt && (
|
||||
<figcaption className="sr-only">{post.excerpt}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
<span className="sr-only">Category: </span>
|
||||
{post.category} •{" "}
|
||||
<time dateTime={date.toISOString()}>{dateStr}</time>
|
||||
</p>
|
||||
<h1
|
||||
id="post-title"
|
||||
className="text-3xl font-bold"
|
||||
itemProp="headline"
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
{post.excerpt && (
|
||||
<p
|
||||
className="text-gray-700 dark:text-gray-300"
|
||||
itemProp="description"
|
||||
>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
className="prose dark:prose-invert max-w-none"
|
||||
itemProp="articleBody"
|
||||
>
|
||||
<p>{post.body}</p>
|
||||
{Array.isArray(post.images) && post.images.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 mt-4">
|
||||
{post.images.map((src, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={src}
|
||||
alt={`${post.title} image ${i + 1}`}
|
||||
className="w-full h-64 object-cover rounded-xl border border-gray-200 dark:border-neutral-800"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* External link */}
|
||||
{post.url && (
|
||||
<p>
|
||||
<a
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:underline"
|
||||
aria-label={`Continue reading ${post.title} on external site`}
|
||||
>
|
||||
Continue reading ↗
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Prev / Next */}
|
||||
{prev && next && (
|
||||
<nav
|
||||
className="mt-8 flex items-center justify-between gap-4 border-t pt-6 border-gray-200 dark:border-neutral-800"
|
||||
aria-label="Post navigation"
|
||||
>
|
||||
<Link
|
||||
to={toSlug(prev)}
|
||||
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
|
||||
aria-label={`Previous post: ${prev.title}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="translate-x-0 group-hover:-translate-x-0.5 transition"
|
||||
>
|
||||
←
|
||||
</span>
|
||||
<span className="truncate max-w-[16rem]">{prev.title}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to={toSlug(next)}
|
||||
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
|
||||
aria-label={`Next post: ${next.title}`}
|
||||
>
|
||||
<span className="truncate max-w-[16rem]">{next.title}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="translate-x-0 group-hover:translate-x-0.5 transition"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Related */}
|
||||
<aside
|
||||
className="lg:sticky lg:top-24 h-fit space-y-4 bg-[var(--card)] text-[var(--text)] p-3 rounded-2xl"
|
||||
aria-labelledby="more-posts-heading"
|
||||
>
|
||||
<h2 id="more-posts-heading" className="text-lg font-semibold">
|
||||
More Posts
|
||||
</h2>
|
||||
<ul className="grid gap-4">
|
||||
{related.map((p) => (
|
||||
<li key={p.id + "-rel"} className="list-none">
|
||||
<article aria-labelledby={`rel-${p.id}-title`}>
|
||||
<Link
|
||||
to={toSlug(p)}
|
||||
className="block rounded-xl border border-gray-200 dark:border-neutral-800 overflow-hidden hover:shadow-sm transition"
|
||||
aria-label={`Read ${p.title}`}
|
||||
>
|
||||
<img
|
||||
src={p.coverImage}
|
||||
alt={p.title}
|
||||
className="w-full h-32 object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-gray-500">{p.category}</p>
|
||||
<h3
|
||||
id={`rel-${p.id}-title`}
|
||||
className="text-sm font-medium line-clamp-2"
|
||||
>
|
||||
{p.title}
|
||||
</h3>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/pages/Contact.jsx
Normal file
58
src/pages/Contact.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import AboutCard from "../components/AboutCard";
|
||||
import ContactDetails from "../components/ContactDetails";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
const SITE_URL = "https://priscy-orcin.vercel.app";
|
||||
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
|
||||
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
|
||||
const TITLE = "Services — Priscy Designs";
|
||||
const DESCRIPTION =
|
||||
"Explore UI/UX, full-stack development, branding, and product design projects.";
|
||||
|
||||
export default function Contact() {
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: TITLE,
|
||||
url: `${SITE_URL}/services`,
|
||||
description: DESCRIPTION,
|
||||
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: LOGO_URL },
|
||||
},
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
canonical={SITE_URL}
|
||||
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
|
||||
twitter={{ image: COVER_URL }}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
<section className="lg:p-0 flex flex-col gap-5">
|
||||
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Left column: sticky card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="sticky top-6">
|
||||
{" "}
|
||||
{/* adjust top-6 for offset under your navbar */}
|
||||
<AboutCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right columns: long scrolling content */}
|
||||
<div className="lg:col-span-2">
|
||||
<ContactDetails />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
src/pages/Home.jsx
Normal file
133
src/pages/Home.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React from "react";
|
||||
import AboutCard from "../components/AboutCard";
|
||||
import WorkExperience from "../components/WorkExperience";
|
||||
import ExpertArea from "../components/ExpertArea";
|
||||
import ServiceOffer from "../components/ServiceOffer";
|
||||
import WorkTogetherCard from "../components/WorkTogetherCard";
|
||||
import ProjectCards from "../components/ProjectCards";
|
||||
import GallerySlider from "../components/GallerySlider";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
// Hero/gallery images with descriptive alt
|
||||
const images = [
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1600&q=80&auto=format&fit=crop",
|
||||
alt: "City skyline at dusk with reflections on water",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1519681393784-d120267933ba?w=1600&q=80&auto=format&fit=crop",
|
||||
alt: "Abstract gradient texture in vibrant colors",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1496307042754-b4aa456c4a2d?w=1600&q=80&auto=format&fit=crop",
|
||||
alt: "Snow-capped mountains surrounding a calm lake",
|
||||
},
|
||||
];
|
||||
|
||||
const SITE_URL = "https://priscy-orcin.vercel.app";
|
||||
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
|
||||
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
|
||||
const TITLE = "Priscy Designs — Portfolio & Services";
|
||||
const DESCRIPTION =
|
||||
"Explore UI/UX, full-stack development, branding, and product design projects.";
|
||||
|
||||
export default function Home() {
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: TITLE,
|
||||
url: SITE_URL,
|
||||
description: DESCRIPTION,
|
||||
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: LOGO_URL },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
canonical={SITE_URL}
|
||||
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
|
||||
twitter={{ image: COVER_URL }}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
{/* Skip link for keyboard users */}
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:bg-white focus:text-black focus:px-3 focus:py-2 rounded"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
{/* Main landmark */}
|
||||
<main id="main" className="lg:p-0 flex flex-col gap-5" role="main">
|
||||
{/* Page heading (only one H1 per page) */}
|
||||
<header className="p-0 mt-14 lg:mt-16">
|
||||
<h1 className="sr-only">Priscy Designs — Portfolio and Services</h1>
|
||||
</header>
|
||||
|
||||
{/* Intro grid: About + Featured Projects */}
|
||||
<section
|
||||
aria-labelledby="featured-projects-heading"
|
||||
className="p-0 flex flex-col gap-5"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<aside className="lg:col-span-1" aria-label="About Priscy">
|
||||
<div className="sticky top-6">
|
||||
<AboutCard />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="col-span-2">
|
||||
<h2
|
||||
id="featured-projects-heading"
|
||||
className="text-2xl font-semibold mb-2"
|
||||
>
|
||||
Featured Projects
|
||||
</h2>
|
||||
{/* 4 items per page? set pagination prop accordingly */}
|
||||
<ProjectCards pagination={false} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Gallery + Work Experience + Expertise */}
|
||||
<section
|
||||
aria-labelledby="work-expertise-heading"
|
||||
className="grid lg:grid-cols-3 gap-5"
|
||||
>
|
||||
<h2 id="work-expertise-heading" className="sr-only">
|
||||
Work, Expertise, and Gallery
|
||||
</h2>
|
||||
|
||||
{/* The slider already has ARIA from earlier; ensure it has a label prop if you add one */}
|
||||
<div aria-label="Recent work gallery">
|
||||
<GallerySlider images={images} interval={4500} />
|
||||
</div>
|
||||
|
||||
<div aria-label="Work experience timeline">
|
||||
<WorkExperience />
|
||||
</div>
|
||||
|
||||
<div aria-label="Expert areas">
|
||||
<ExpertArea />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services & CTA */}
|
||||
<section
|
||||
aria-labelledby="services-cta-heading"
|
||||
className="flex flex-col lg:grid lg:grid-cols-3 gap-5"
|
||||
>
|
||||
<ServiceOffer />
|
||||
<WorkTogetherCard />
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/pages/Portfolio.jsx
Normal file
59
src/pages/Portfolio.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import AboutCard from "../components/AboutCard";
|
||||
import ProjectDetails from "../components/ProjectDetails";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
const SITE_URL = "https://priscy-orcin.vercel.app";
|
||||
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
|
||||
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
|
||||
const TITLE = "Portfolio — Priscy Designs";
|
||||
const DESCRIPTION =
|
||||
"Explore UI/UX, full-stack development, branding, and product design projects.";
|
||||
|
||||
export default function Portfolio() {
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: TITLE,
|
||||
url: `${SITE_URL}/portfolio`,
|
||||
description: DESCRIPTION,
|
||||
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: LOGO_URL },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
canonical={SITE_URL}
|
||||
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
|
||||
twitter={{ image: COVER_URL }}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
<section className="lg:p-0 flex flex-col gap-5">
|
||||
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Left column: sticky card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="sticky top-6">
|
||||
{" "}
|
||||
{/* adjust top-6 for offset under your navbar */}
|
||||
<AboutCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right columns: long scrolling content */}
|
||||
<div className="lg:col-span-2">
|
||||
<ProjectDetails />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
320
src/pages/ProductDetail.jsx
Normal file
320
src/pages/ProductDetail.jsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import products from "../data/projects.json";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
function slugify(s) {
|
||||
return (s || "")
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)+/g, "");
|
||||
}
|
||||
|
||||
// tiny seeded RNG so "random" is stable per product
|
||||
function seededRandom(seed) {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return () => {
|
||||
h += 0x6d2b79f5;
|
||||
let t = Math.imul(h ^ (h >>> 15), 1 | h);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProductDetail() {
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// find product + index (hook always called)
|
||||
const { product, index } = useMemo(() => {
|
||||
const bySlug = products.findIndex((p) => p.slug === slug);
|
||||
if (bySlug !== -1) return { product: products[bySlug], index: bySlug };
|
||||
|
||||
const byTitle = products.findIndex((p) => slugify(p.title) === slug);
|
||||
if (byTitle !== -1) return { product: products[byTitle], index: byTitle };
|
||||
|
||||
return { product: null, index: -1 };
|
||||
}, [slug]);
|
||||
|
||||
// related list (hook always called; returns [] when no product)
|
||||
const related = useMemo(() => {
|
||||
if (!product) return [];
|
||||
const pool = products.filter((p) => p !== product);
|
||||
const rng = seededRandom(product.slug ?? slugify(product.title));
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
return pool.slice(0, 4);
|
||||
}, [product]);
|
||||
|
||||
// prev/next (compute even if product is null; values only used when product exists)
|
||||
const prevIndex =
|
||||
index === -1 ? -1 : (index - 1 + products.length) % products.length;
|
||||
const nextIndex = index === -1 ? -1 : (index + 1) % products.length;
|
||||
const prev = prevIndex === -1 ? null : products[prevIndex];
|
||||
const next = nextIndex === -1 ? null : products[nextIndex];
|
||||
const toSlug = (p) => `/portfolio/${p.slug ?? slugify(p.title)}`;
|
||||
|
||||
// now it’s safe to conditionally return
|
||||
if (!product) {
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title="Project not found — Priscy Designs"
|
||||
description="The requested project could not be found."
|
||||
/>
|
||||
<div className="py-12 text-center">
|
||||
<h1 className="text-2xl font-semibold mb-2">Product not found</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
We couldn’t find a product with that URL.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
← Go Back
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ✅ Build SEO values from the product ---------- */
|
||||
const prodSlug = product.slug ?? slugify(product.title); //replace with your actual product slug/url
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://your-domain.com";
|
||||
const canonical = `${origin}/portfolio/${prodSlug}`;
|
||||
const title = `${product.title} — Priscy Designs`;
|
||||
const description =
|
||||
product.body?.slice(0, 160) || "Project case study from Priscy Designs.";
|
||||
const cover = product.coverImage ?? product.images?.[0] ?? "/og-cover.jpg";
|
||||
const logo = "/logo.png";
|
||||
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: title,
|
||||
url: canonical,
|
||||
description,
|
||||
primaryImageOfPage: { "@type": "ImageObject", url: cover },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: logo },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={title}
|
||||
description={description}
|
||||
canonical={canonical}
|
||||
og={{
|
||||
url: canonical,
|
||||
image: cover,
|
||||
siteName: "Priscy Designs",
|
||||
title,
|
||||
description,
|
||||
}}
|
||||
twitter={{
|
||||
image: cover,
|
||||
title,
|
||||
description,
|
||||
card: "summary_large_image",
|
||||
}}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
|
||||
<main
|
||||
id="main"
|
||||
role="main"
|
||||
className="mt-14 lg:mt-16"
|
||||
aria-labelledby="project-title"
|
||||
>
|
||||
<section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,28%)]">
|
||||
{/* Main project */}
|
||||
<article
|
||||
className="space-y-6 bg-[var(--card)] text-[var(--text)] rounded-2xl p-3 h-fit"
|
||||
itemScope
|
||||
itemType="https://schema.org/CreativeWork"
|
||||
aria-labelledby="project-title"
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<li>
|
||||
<Link to="/portfolio" className="hover:underline">
|
||||
Portfolio
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li
|
||||
aria-current="page"
|
||||
className="text-gray-600 dark:text-gray-300 truncate max-w-[60ch]"
|
||||
>
|
||||
{product.title}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<figure className="rounded-2xl overflow-hidden border border-gray-200 dark:border-neutral-800">
|
||||
<img
|
||||
src={product.coverImage ?? product.images?.[0]}
|
||||
alt={product.title}
|
||||
className="w-full h-72 object-cover"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
itemProp="image"
|
||||
/>
|
||||
{description && (
|
||||
<figcaption className="sr-only">{description}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-sm">
|
||||
<span className="sr-only">Category: </span>
|
||||
{product.category}
|
||||
</p>
|
||||
<h1
|
||||
id="project-title"
|
||||
className="text-3xl font-bold"
|
||||
itemProp="name"
|
||||
>
|
||||
{product.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="text-lg dark:text-gray-300" itemProp="description">
|
||||
<p>{product.body}</p>
|
||||
</div>
|
||||
|
||||
{/* Gallery */}
|
||||
{Array.isArray(product.images) && product.images.length > 0 && (
|
||||
<div
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
aria-label="Project gallery"
|
||||
>
|
||||
{product.images.map((src, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={src}
|
||||
alt={`${product.title} image ${i + 1}`}
|
||||
className="w-full h-64 object-cover rounded-xl border border-gray-200 dark:border-neutral-800"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External URL */}
|
||||
{product.url && (
|
||||
<p>
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:underline"
|
||||
aria-label={`Visit external project page for ${product.title}`}
|
||||
>
|
||||
Visit Project ↗
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Prev / Next */}
|
||||
{prev && next && (
|
||||
<nav
|
||||
className="mt-8 flex items-center justify-between gap-4 border-t pt-6 border-gray-200 dark:border-neutral-800"
|
||||
aria-label="Project navigation"
|
||||
>
|
||||
<Link
|
||||
to={toSlug(prev)}
|
||||
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
|
||||
aria-label={`Previous project: ${prev.title}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="translate-x-0 group-hover:-translate-x-0.5 transition"
|
||||
>
|
||||
←
|
||||
</span>
|
||||
<span className="truncate max-w-[16rem]">{prev.title}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to={toSlug(next)}
|
||||
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
|
||||
aria-label={`Next project: ${next.title}`}
|
||||
>
|
||||
<span className="truncate max-w-[16rem]">{next.title}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="translate-x-0 group-hover:translate-x-0.5 transition"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Related projects */}
|
||||
<aside
|
||||
className="lg:sticky lg:top-24 h-fit space-y-4 bg-[var(--card)] text-[var(--text)] p-3 rounded-2xl"
|
||||
aria-labelledby="more-projects-heading"
|
||||
>
|
||||
<h2 id="more-projects-heading" className="text-lg font-semibold">
|
||||
More Projects
|
||||
</h2>
|
||||
<ul className="grid gap-4">
|
||||
{related.map((p) => (
|
||||
<li key={(p.id ?? p.title) + "-rel"} className="list-none">
|
||||
<article
|
||||
aria-labelledby={`rel-${p.id ?? slugify(p.title)}-title`}
|
||||
>
|
||||
<Link
|
||||
to={toSlug(p)}
|
||||
className="block rounded-xl border border-gray-200 dark:border-neutral-800 overflow-hidden hover:shadow-sm transition"
|
||||
aria-label={`Open project ${p.title}`}
|
||||
>
|
||||
<img
|
||||
src={p.coverImage ?? p.images?.[0]}
|
||||
alt={p.title}
|
||||
className="w-full h-32 object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<p className="text-xs text-gray-500">{p.category}</p>
|
||||
<h3
|
||||
id={`rel-${p.id ?? slugify(p.title)}-title`}
|
||||
className="text-sm font-medium line-clamp-2"
|
||||
>
|
||||
{p.title}
|
||||
</h3>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/pages/Services.jsx
Normal file
59
src/pages/Services.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import AboutCard from "../components/AboutCard";
|
||||
import ServiceDetails from "../components/ServiceDetails";
|
||||
import Head from "../lib/Head";
|
||||
|
||||
const SITE_URL = "https://priscy-orcin.vercel.app";
|
||||
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
|
||||
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
|
||||
const TITLE = "Services — Priscy Designs";
|
||||
const DESCRIPTION =
|
||||
"Explore UI/UX, full-stack development, branding, and product design projects.";
|
||||
|
||||
export default function Services() {
|
||||
const jsonLdWebPage = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: TITLE,
|
||||
url: `${SITE_URL}/services`,
|
||||
description: DESCRIPTION,
|
||||
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Priscy Designs",
|
||||
logo: { "@type": "ImageObject", url: LOGO_URL },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
canonical={SITE_URL}
|
||||
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
|
||||
twitter={{ image: COVER_URL }}
|
||||
jsonLd={jsonLdWebPage}
|
||||
/>
|
||||
<section className="lg:p-0 flex flex-col gap-5">
|
||||
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Left column: sticky card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="sticky top-6">
|
||||
{" "}
|
||||
{/* adjust top-6 for offset under your navbar */}
|
||||
<AboutCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right columns: long scrolling content */}
|
||||
<div className="lg:col-span-2">
|
||||
<ServiceDetails />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user