feat: enable category slug pages
This commit is contained in:
@@ -4,6 +4,7 @@ import { PublicLayout } from "./layouts/PublicLayout";
|
|||||||
import { Home } from "./pages/Home";
|
import { Home } from "./pages/Home";
|
||||||
import { Browse } from "./pages/Browse";
|
import { Browse } from "./pages/Browse";
|
||||||
import { CategoriesPage } from "./pages/Categories";
|
import { CategoriesPage } from "./pages/Categories";
|
||||||
|
import { CategoryPage } from "./pages/Category";
|
||||||
import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations";
|
import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations";
|
||||||
import { SearchPage } from "./pages/Search";
|
import { SearchPage } from "./pages/Search";
|
||||||
import { PostRedirect } from "./pages/PostRedirect";
|
import { PostRedirect } from "./pages/PostRedirect";
|
||||||
@@ -33,10 +34,7 @@ export default function App() {
|
|||||||
path="/official-recommendations"
|
path="/official-recommendations"
|
||||||
element={<OfficialRecommendationsPage />}
|
element={<OfficialRecommendationsPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="/category/:slug" element={<CategoryPage />} />
|
||||||
path="/category/:slug"
|
|
||||||
element={<Navigate to="/browse" replace />}
|
|
||||||
/>
|
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route path="/search" element={<SearchPage />} />
|
||||||
<Route path="/resource/:id" element={<PostRedirect />} />
|
<Route path="/resource/:id" element={<PostRedirect />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
|||||||
19
src/components/AssetStreamPage.tsx
Normal file
19
src/components/AssetStreamPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { PostScope } from "../types/post";
|
||||||
|
import { MessageStream } from "./messageStream/MessageStream";
|
||||||
|
import { SectionHeader } from "./SectionHeader";
|
||||||
|
|
||||||
|
type AssetStreamPageProps = {
|
||||||
|
title: string;
|
||||||
|
scope: PostScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AssetStreamPage({ title, scope }: AssetStreamPageProps) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="mx-auto max-w-full px-4 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||||
|
<SectionHeader title={title} />
|
||||||
|
</div>
|
||||||
|
<MessageStream scope={scope} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { MessageStream } from "../../components/messageStream/MessageStream";
|
import { AssetStreamPage } from "../../components/AssetStreamPage";
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
|
|
||||||
export function Browse() {
|
export function Browse() {
|
||||||
@@ -17,12 +16,5 @@ export function Browse() {
|
|||||||
: sort === "popular"
|
: sort === "popular"
|
||||||
? t("popular")
|
? t("popular")
|
||||||
: t("all");
|
: t("all");
|
||||||
return (
|
return <AssetStreamPage title={title} scope={{ kind: "all" }} />;
|
||||||
<section>
|
|
||||||
<div className="mx-auto max-w-full px-4 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
|
||||||
<SectionHeader title={title} />
|
|
||||||
</div>
|
|
||||||
<MessageStream scope={{ kind: "all" }} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
import { CategoryIcon } from "../../components/CategoryIcon";
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
@@ -30,7 +31,6 @@ function figmaCategoryRank(category: Category): number {
|
|||||||
export function CategoriesPage() {
|
export function CategoriesPage() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
const [unavailableOpen, setUnavailableOpen] = useState(false);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,10 +60,9 @@ export function CategoriesPage() {
|
|||||||
<SectionHeader title={t("categorySection")} />
|
<SectionHeader title={t("categorySection")} />
|
||||||
<div className="mt-7 grid grid-cols-3 gap-2 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
<div className="mt-7 grid grid-cols-3 gap-2 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
||||||
{cats.map((category) => (
|
{cats.map((category) => (
|
||||||
<button
|
<Link
|
||||||
key={category.id}
|
key={category.id}
|
||||||
type="button"
|
to={`/category/${encodeURIComponent(category.slug)}`}
|
||||||
onClick={() => setUnavailableOpen(true)}
|
|
||||||
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
@@ -74,41 +73,9 @@ export function CategoriesPage() {
|
|||||||
<div className="w-full text-center text-[13px] font-medium leading-[19.5px] text-white line-clamp-2">
|
<div className="w-full text-center text-[13px] font-medium leading-[19.5px] text-white line-clamp-2">
|
||||||
{cleanCategoryDisplayName(category.name)}
|
{cleanCategoryDisplayName(category.name)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unavailableOpen ? (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 px-6 backdrop-blur-sm"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="category-unavailable-title"
|
|
||||||
onClick={() => setUnavailableOpen(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-[320px] rounded-2xl border border-[#27292E] bg-[#1D1E23] p-5 text-center shadow-2xl"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id="category-unavailable-title"
|
|
||||||
className="text-xl font-bold text-white"
|
|
||||||
>
|
|
||||||
{t("featureUnavailable")}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[#A8A9AE]">
|
|
||||||
{t("featureUnavailableDesc")}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setUnavailableOpen(false)}
|
|
||||||
className="mt-5 h-10 w-full rounded-full bg-ark-gold text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1D1E23]"
|
|
||||||
>
|
|
||||||
{t("confirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
import { MessageStream } from "../../components/messageStream/MessageStream";
|
import { AssetStreamPage } from "../../components/AssetStreamPage";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
|
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
const { slug = "" } = useParams();
|
const { slug = "" } = useParams();
|
||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>(slug);
|
||||||
|
const scope = useMemo(() => ({ kind: "category" as const, slug }), [slug]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
setTitle(slug);
|
||||||
getJSON<Category[]>(
|
getJSON<Category[]>(
|
||||||
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
|
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
)
|
)
|
||||||
.then((cats) =>
|
.then((cats) =>
|
||||||
setTitle(itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug),
|
setTitle(
|
||||||
|
cleanCategoryDisplayName(
|
||||||
|
itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.catch(() => setTitle(slug));
|
.catch(() => setTitle(slug));
|
||||||
}, [slug, lang]);
|
}, [slug, lang]);
|
||||||
|
|
||||||
return (
|
return <AssetStreamPage title={title || slug} scope={scope} />;
|
||||||
<section className="space-y-3">
|
|
||||||
<h1 className="mx-auto max-w-full px-3 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
|
||||||
{title || slug}
|
|
||||||
</h1>
|
|
||||||
<MessageStream scope={{ kind: "category", slug }} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
import { CategoryIcon } from "../../components/CategoryIcon";
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
||||||
@@ -51,7 +51,6 @@ export function Home() {
|
|||||||
const [latestPosts, setLatestPosts] = useState<Post[]>([]);
|
const [latestPosts, setLatestPosts] = useState<Post[]>([]);
|
||||||
const [popular, setPopular] = useState<PostBackedResource[]>([]);
|
const [popular, setPopular] = useState<PostBackedResource[]>([]);
|
||||||
const [popularPosts, setPopularPosts] = useState<Post[]>([]);
|
const [popularPosts, setPopularPosts] = useState<Post[]>([]);
|
||||||
const [categoryUnavailableOpen, setCategoryUnavailableOpen] = useState(false);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const recRowRef = useRef<HTMLDivElement>(null);
|
const recRowRef = useRef<HTMLDivElement>(null);
|
||||||
const latestRowRef = useRef<HTMLDivElement>(null);
|
const latestRowRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -253,10 +252,9 @@ export function Home() {
|
|||||||
className="grid w-full shrink-0 snap-start grid-cols-3 gap-2 px-4"
|
className="grid w-full shrink-0 snap-start grid-cols-3 gap-2 px-4"
|
||||||
>
|
>
|
||||||
{page.map((c) => (
|
{page.map((c) => (
|
||||||
<button
|
<Link
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
to={`/category/${encodeURIComponent(c.slug)}`}
|
||||||
onClick={() => setCategoryUnavailableOpen(true)}
|
|
||||||
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
@@ -267,7 +265,7 @@ export function Home() {
|
|||||||
<div className="w-full truncate text-[13px] font-medium leading-[19.5px] text-white">
|
<div className="w-full truncate text-[13px] font-medium leading-[19.5px] text-white">
|
||||||
{cleanCategoryDisplayName(c.name)}
|
{cleanCategoryDisplayName(c.name)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -306,10 +304,9 @@ export function Home() {
|
|||||||
|
|
||||||
<div className="mt-7 hidden grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
<div className="mt-7 hidden grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
||||||
{figmaOrderedCategories.map((c) => (
|
{figmaOrderedCategories.map((c) => (
|
||||||
<button
|
<Link
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
to={`/category/${encodeURIComponent(c.slug)}`}
|
||||||
onClick={() => setCategoryUnavailableOpen(true)}
|
|
||||||
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
@@ -320,7 +317,7 @@ export function Home() {
|
|||||||
<div className="w-full text-center text-[13px] font-medium leading-[19.5px] text-white line-clamp-2">
|
<div className="w-full text-center text-[13px] font-medium leading-[19.5px] text-white line-clamp-2">
|
||||||
{cleanCategoryDisplayName(c.name)}
|
{cleanCategoryDisplayName(c.name)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -486,38 +483,6 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{categoryUnavailableOpen ? (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 px-6 backdrop-blur-sm"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="category-unavailable-title"
|
|
||||||
onClick={() => setCategoryUnavailableOpen(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-[320px] rounded-2xl border border-[#27292E] bg-[#1D1E23] p-5 text-center shadow-2xl"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id="category-unavailable-title"
|
|
||||||
className="text-xl font-bold text-white"
|
|
||||||
>
|
|
||||||
{t("featureUnavailable")}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[#A8A9AE]">
|
|
||||||
{t("featureUnavailableDesc")}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCategoryUnavailableOpen(false)}
|
|
||||||
className="mt-5 h-10 w-full rounded-full bg-ark-gold text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1D1E23]"
|
|
||||||
>
|
|
||||||
{t("confirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user