Overview
Loading...
import { useState, useMemo } from "react";
import type { ActivityComponentType } from "@stackflow/react/future";
import { AppBar, AppBarMain } from "seed-design/ui/app-bar";
import { AppScreen, AppScreenContent } from "seed-design/ui/app-screen";
import { TabsRoot, TabsTrigger, TabsList, TabsCarousel, TabsContent } from "seed-design/ui/tabs";
import { SnackbarProvider } from "seed-design/ui/snackbar";
import { ResultSection } from "seed-design/ui/result-section";
import { IconArticleFill, IconChevronDownFill } from "@karrotmarket/react-monochrome-icon";
import { Flex, HStack, VStack, Icon, Box, Text, Badge, TagGroup, Portal } from "@seed-design/react";
import { Chip } from "seed-design/ui/chip";
import {
BottomSheetBody,
BottomSheetRoot,
BottomSheetContent,
BottomSheetFooter,
BottomSheetTrigger,
} from "seed-design/ui/bottom-sheet";
import { ActionButton } from "seed-design/ui/action-button";
import { Snackbar, useSnackbarAdapter } from "seed-design/ui/snackbar";
import { ARTICLES, CATEGORIES, type Article, type Category } from "../demo-data";
import { useFlow } from "@stackflow/react/future";
import { Avatar } from "seed-design/ui/avatar";
import { IdentityPlaceholder } from "seed-design/ui/identity-placeholder";
import { formatDate } from "../utils/date";
import { useActivityZIndexBase } from "@seed-design/stackflow";
import { tabsCarouselPreventDrag } from "@seed-design/react";
declare module "@stackflow/config" {
interface Register {
ActivityDemoHome: {};
}
}
const TABS = [
{ label: "추천", value: "recommendations" },
{ label: "구독", value: "subscriptions" },
] as const satisfies {
label: string;
value: string;
}[];
type Tab = (typeof TABS)[number]["value"];
const FILTERS = [
{ label: "카테고리", value: "category" },
{ label: "동네", value: "location" },
{ label: "작성자", value: "author" },
{ label: "작성 시간", value: "createdAt" },
] as const satisfies {
label: string;
value: string;
}[];
type Filter = (typeof FILTERS)[number]["value"];
const ActivityDemoHome: ActivityComponentType<"ActivityDemoHome"> = () => {
const [tab, setTab] = useState<Tab>("recommendations");
return (
<SnackbarProvider>
<style
// biome-ignore lint/security/noDangerouslySetInnerHtml: this is for hiding scrollbar
dangerouslySetInnerHTML={{
__html: "::-webkit-scrollbar{display:none}",
}}
/>
<AppScreen>
<AppBar>
<AppBarMain title="Demo" />
</AppBar>
<AppScreenContent>
<TabsRoot
value={tab}
onValueChange={(value) => setTab(value as Tab)}
triggerLayout="fill"
size="medium"
stickyList
>
<TabsList>
{TABS.map(({ label, value }) => (
<TabsTrigger key={value} value={value}>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsCarousel swipeable>
<TabsContent value={TABS[0].value}>
<Recommendations />
</TabsContent>
<TabsContent value={TABS[1].value}>
<VStack py="x12">
<ResultSection
asset={
<Box pb="x4">
<Icon svg={<IconArticleFill />} size="x10" color="fg.neutralSubtle" />
</Box>
}
title="구독한 글이 없습니다."
description="추천 글을 확인해보세요."
primaryActionProps={{
children: "추천 글 보기",
onClick: () => setTab("recommendations"),
}}
/>
</VStack>
</TabsContent>
</TabsCarousel>
</TabsRoot>
</AppScreenContent>
</AppScreen>
</SnackbarProvider>
);
};
export function Recommendations() {
const [currentFilterBottomSheet, setCurrentFilterBottomSheet] = useState<Filter | null>(null);
const defaultFilters = useMemo(
() => ({
category: [],
location: [],
author: [],
createdAt: [],
}),
[],
);
const [selectedFilters, setSelectedFilters] = useState<Record<Filter, string[]>>(defaultFilters);
const adapter = useSnackbarAdapter();
const onUnavailableFilterClick = () =>
adapter.create({
render: () => (
<Snackbar
message="카테고리로만 필터링할 수 있어요."
variant="critical"
actionLabel="확인"
onAction={adapter.dismiss}
/>
),
});
const filteredArticles = useMemo(() => {
let filtered = ARTICLES;
if (selectedFilters.category?.length) {
filtered = ARTICLES.filter((article) =>
selectedFilters.category?.includes(article.categoryId),
);
}
// XXX: Add more filters if needed
return filtered;
}, [selectedFilters]);
const handleFilterConfirm = (filter: Filter, values: string[]) => {
setSelectedFilters((prev) => ({ ...prev, [filter]: values }));
};
return (
<VStack gap="spacingY.componentDefault" py="x4">
<Flex
gap="spacingX.betweenChips"
px="spacingX.globalGutter"
overflowX="auto"
{...tabsCarouselPreventDrag}
>
{FILTERS.map(({ label, value }) => (
<BottomSheetRoot
key={value}
closeOnEscape
closeOnInteractOutside
open={currentFilterBottomSheet === value}
onOpenChange={(open) => setCurrentFilterBottomSheet(open ? value : null)}
>
{value === "category" ? (
<BottomSheetTrigger asChild>
<Chip.Button onClick={value !== "category" ? onUnavailableFilterClick : undefined}>
<Chip.Label>
{selectedFilters[value]?.length
? selectedFilters[value]
.map((id) => CATEGORIES.find((c) => c.id === id)?.name)
.join(", ") || label
: label}
</Chip.Label>
<Chip.SuffixIcon>
<Icon svg={<IconChevronDownFill />} />
</Chip.SuffixIcon>
</Chip.Button>
</BottomSheetTrigger>
) : (
<Chip.Button onClick={onUnavailableFilterClick}>
<Chip.Label>{label}</Chip.Label>
<Chip.SuffixIcon>
<Icon svg={<IconChevronDownFill />} />
</Chip.SuffixIcon>
</Chip.Button>
)}
<Portal>
<FilterBottomSheet
filter={value}
currentFilter={selectedFilters[value]}
onClose={() => setCurrentFilterBottomSheet(null)}
onConfirm={(values) => handleFilterConfirm(value, values)}
/>
</Portal>
</BottomSheetRoot>
))}
</Flex>
<VStack gap="x4" as="ul">
{filteredArticles
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((article) => (
<li key={article.id}>
<ArticleListItem {...article} />
</li>
))}
</VStack>
</VStack>
);
}
export function FilterBottomSheet({
filter,
currentFilter,
onClose,
onConfirm,
}: {
filter: string;
currentFilter: string[];
onClose: () => void;
onConfirm: (values: string[]) => void;
}) {
const options = useMemo(() => {
switch (filter) {
case "category":
return CATEGORIES;
// Add more cases for other filters if needed
default:
return [];
}
}, [filter]);
const [selectedOptions, setSelectedOptions] = useState<string[]>(currentFilter);
return (
<BottomSheetContent
title={FILTERS.find((f) => f.value === filter)?.label}
layerIndex={useActivityZIndexBase({ activityOffset: 1 })}
>
<BottomSheetBody>
<HStack gap="x2" wrap>
{options.map((option: Category) => (
<Chip.Toggle
variant="outlineStrong"
key={option.id}
checked={selectedOptions.includes(option.id)}
onCheckedChange={(checked) =>
setSelectedOptions((prev) =>
checked ? [...prev, option.id] : prev.filter((id) => id !== option.id),
)
}
>
<Chip.Label>{option.name}</Chip.Label>
</Chip.Toggle>
))}
</HStack>
</BottomSheetBody>
<BottomSheetFooter>
<ActionButton
variant="neutralSolid"
onClick={() => {
onConfirm(selectedOptions);
onClose();
}}
>
완료
</ActionButton>
</BottomSheetFooter>
</BottomSheetContent>
);
}
type ArticleProps = Article & {};
export function ArticleListItem(article: ArticleProps) {
const { title, content, author, categoryId, createdAt, isPopular } = article;
const categoryName = CATEGORIES.find((c) => c.id === categoryId)?.name;
const { push } = useFlow();
return (
<VStack
as="button"
onClick={() => push("ActivityDemoArticleDetail", { articleId: article.id })}
style={{ textAlign: "start" }}
gap="x2_5"
px="spacingX.globalGutter"
py="x1"
>
<HStack justify="space-between" align="center">
<HStack gap="x1_5" align="center" flexGrow={1}>
<Avatar
fallback={<IdentityPlaceholder identity="person" />}
size="20"
style={{ zIndex: -1 }}
/>
<Text textStyle="t4Medium" color="fg.neutral">
{author}
</Text>
</HStack>
</HStack>
<VStack gap="x2">
<VStack gap="x1">
<Text as="h1" textStyle="t5Bold" color="fg.neutral" maxLines={1}>
{title}
</Text>
<Text as="p" textStyle="t4Regular" color="fg.neutralMuted" maxLines={2}>
{content}
</Text>
</VStack>
<HStack align="center" gap="x2">
{isPopular && (
<Badge variant="outline" tone="brand">
인기
</Badge>
)}
<TagGroup.Root size="t4" tone="neutralSubtle">
<TagGroup.Item>{categoryName}</TagGroup.Item>
<TagGroup.Item>서초2동</TagGroup.Item>
<TagGroup.Item>{formatDate(createdAt)}</TagGroup.Item>
</TagGroup.Root>
</HStack>
</VStack>
</VStack>
);
}
export default ActivityDemoHome;import type { ActivityComponentType } from "@stackflow/react/future";
import { AppScreen, AppScreenContent } from "seed-design/ui/app-screen";
import {
AppBar,
AppBarBackButton,
AppBarCloseButton,
AppBarRight,
AppBarLeft,
} from "seed-design/ui/app-bar";
import type { AppBarProps } from "seed-design/ui/app-bar";
import { VStack, HStack, Box, Article as SeedArticle, TagGroup } from "@seed-design/react";
import { Text } from "@seed-design/react";
import { Badge } from "@seed-design/react";
import { SegmentedControl, SegmentedControlItem } from "seed-design/ui/segmented-control";
import { Callout } from "seed-design/ui/callout";
import { TextField, TextFieldTextarea } from "seed-design/ui/text-field";
import { ActionButton } from "seed-design/ui/action-button";
import { Skeleton } from "@seed-design/react";
import { IconILowercaseSerifCircleFill } from "@karrotmarket/react-monochrome-icon";
import { useState, useEffect, useRef } from "react";
import { ResultSection } from "seed-design/ui/result-section";
import { CATEGORIES, type Article, ARTICLES } from "../demo-data";
import { Avatar } from "seed-design/ui/avatar";
import { IdentityPlaceholder } from "seed-design/ui/identity-placeholder";
import { formatDate } from "../utils/date";
import img from "../assets/peng.jpeg";
declare module "@stackflow/config" {
interface Register {
ActivityDemoArticleDetail: {
articleId: Article["id"];
};
}
}
const SEGMENTS = [
{ value: "popular", label: "인기" },
{ value: "latest", label: "최근" },
] as const satisfies { value: string; label: string }[];
const ActivityDemoArticleDetail: ActivityComponentType<"ActivityDemoArticleDetail"> = ({
params: { articleId },
}: {
params: { articleId: Article["id"] };
}) => {
const article = ARTICLES.find((a) => a.id === articleId);
const categoryName = CATEGORIES.find((c) => c.id === article?.categoryId)?.name;
const [isImageLoading, setIsImageLoading] = useState(true);
const [tone, setTone] = useState<AppBarProps["tone"]>("transparent");
const imageBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setTone(entry.isIntersecting ? "transparent" : "layer"),
{ threshold: [0, 0.1, 0.5, 1], rootMargin: "0px" },
);
if (imageBoxRef.current) {
observer.observe(imageBoxRef.current);
}
return () => {
observer.disconnect();
};
}, []);
if (!article) return null;
return (
<AppScreen layerOffsetTop="none" tone={tone}>
<AppBar>
<AppBarLeft>
<AppBarBackButton />
</AppBarLeft>
<AppBarRight>
<AppBarCloseButton />
</AppBarRight>
</AppBar>
<AppScreenContent>
<VStack gap="x4">
<Box
ref={imageBoxRef}
style={{ aspectRatio: "1 / 1", position: "relative", isolation: "isolate" }}
>
<img
src={img}
alt="penguin"
onLoad={() => setIsImageLoading(false)}
style={{
position: "absolute",
zIndex: 1,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{isImageLoading && <Skeleton width="full" height="full" radius="0" />}
</Box>
<VStack gap="x6" pb="x4">
<VStack px="spacingX.globalGutter" gap="spacingY.componentDefault" align="flex-start">
{article.isPopular && (
<Badge variant="outline" tone="brand" size="large">
인기
</Badge>
)}
<VStack gap="x2" asChild>
<SeedArticle>
<Text as="h1" textStyle="t7Bold" color="fg.neutral">
{article.title}
</Text>
<Text
as="p"
textStyle="articleBody"
color="fg.neutralMuted"
style={{ wordBreak: "keep-all" }}
>
{article.content}
</Text>
</SeedArticle>
</VStack>
<HStack width="full" align="center">
<HStack gap="x1_5" align="center" flexGrow={1}>
<Avatar
fallback={<IdentityPlaceholder identity="person" />}
size="20"
style={{ zIndex: -1 }}
/>
<Text textStyle="t4Medium" color="fg.neutral">
{article.author}
</Text>
</HStack>
<TagGroup.Root size="t3" tone="neutralSubtle">
<TagGroup.Item>{categoryName}</TagGroup.Item>
<TagGroup.Item>{formatDate(article.createdAt)}</TagGroup.Item>
</TagGroup.Root>
</HStack>
</VStack>
<VStack px="spacingX.globalGutter" gap="spacingY.componentDefault">
<Callout
tone="neutral"
description="따뜻한 댓글을 남겨주세요."
prefixIcon={<IconILowercaseSerifCircleFill />}
/>
<SegmentedControl
aria-label="댓글 정렬 방식"
defaultValue={SEGMENTS[0].value}
style={{ width: "100%" }}
>
{SEGMENTS.map((tab) => (
<SegmentedControlItem key={tab.value} value={tab.value}>
{tab.label}
</SegmentedControlItem>
))}
</SegmentedControl>
<Box py="x3">
<ResultSection size="medium" title="댓글 없음" description="댓글이 없습니다." />
</Box>
<TextField label="댓글" maxGraphemeCount={200}>
<TextFieldTextarea placeholder="저는…" />
</TextField>
<ActionButton size="large" variant="neutralSolid">
게시
</ActionButton>
</VStack>
</VStack>
</VStack>
</AppScreenContent>
</AppScreen>
);
};
export default ActivityDemoArticleDetail;Last updated on