Changes
5 changed files (+304/-32)
-
-
@@ -0,0 +1,50 @@/* * SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .appbar { border-block-end: 1px solid var(--gray-a6); background-color: var(--color-panel-solid); z-index: 50; } .menu { background-color: var(--color-background); z-index: 30; view-transition-name: menu; } .logo { display: inline-flex; padding: var(--space-1); border-radius: var(--radius-1); color: var(--gray-12); } .logo:focus-visible { color: var(--focus-11); outline: 2px solid var(--focus-a8); } @keyframes slidein { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0px); opacity: 1; } } ::view-transition-new(menu) { animation-name: slidein; } ::view-transition-old(menu) { animation-name: slidein; animation-direction: reverse; }
-
-
-
@@ -0,0 +1,77 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; import { Button, IconButton } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx"; import { ThirdPartyNoticeProvider } from "../components/CopyrightNotice.ts"; import { LoggedInLayout } from "./LoggedInLayout.tsx"; export default { component: LoggedInLayout, args: { title: "TITLE", children: "CHILDREN", user: create(UserSchema, {}), }, parameters: { layout: "fullscreen", }, decorators: [ (Story) => ( <ThirdPartyNoticeProvider text="Foo"> <Story /> </ThirdPartyNoticeProvider> ), withInmemoryRouter(), ], } satisfies Meta<typeof LoggedInLayout>; type Story = StoryObj<typeof LoggedInLayout>; export const Defaults: Story = {}; export const MenuOpened: Story = { args: { defaultMenuOpened: true, }, }; export const WithTextAction: Story = { args: { actions: <Button>保存</Button>, }, }; export const WithIconActions: Story = { args: { actions: ( <> <IconButton variant="surface"> <TrashIcon /> </IconButton> <IconButton variant="surface"> <Pencil2Icon /> </IconButton> </> ), }, }; export const LongContents: Story = { args: { children: ( <ul> {Array.from({ length: 100 }, (_, i) => ( <li key={i}>Foo</li> ))} </ul> ), }, };
-
-
-
@@ -0,0 +1,146 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { HamburgerMenuIcon } from "@radix-ui/react-icons"; import { Box, type BoxProps, Container, Flex, Grid, Heading, IconButton, ScrollArea, Text, } from "@radix-ui/themes"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC, type ReactNode, useCallback, useState } from "react"; import * as CopyrightNotice from "../components/CopyrightNotice.ts"; import { Logo } from "../components/Logo.tsx"; import * as NavigationMenu from "../components/NavigationMenu.ts"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import css from "./LoggedInLayout.module.css"; export interface LoggedInLayoutProps extends Pick<BoxProps, "className" | "style"> { /** * AppBar に表示するタイトル。 */ title: ReactNode; /** * ページの主要素。 */ children?: ReactNode; /** * この画面 (シーン) にグローバルな操作・アクション。 * Flex コンテナ内となるため、 Fragment で渡すとレイアウトされる。 */ actions?: ReactNode; defaultMenuOpened?: boolean; /** * ログイン中のユーザ。 */ user: User; } export const LoggedInLayout: FC<LoggedInLayoutProps> = ({ title, children, actions, defaultMenuOpened = false, user, ...rest }) => { const viewTransition = useViewTransition(); const [isMenuOpened, setIsMenuOpened] = useState(defaultMenuOpened); const toggleMenu = useCallback(() => { viewTransition(() => { setIsMenuOpened((prev) => !prev); }); }, [viewTransition]); return ( <Grid {...rest} inset="0" height="100%" columns={{ initial: "1", md: "minmax(15rem, max-content) minmax(0, 1fr)" }} rows="max-content minmax(0, 1fr)" > <Flex className={css.appbar} display={{ initial: "none", md: "flex" }} align="center" justify="center" > <a className={css.logo} href="/"> <Logo /> </a> </Flex> <Flex className={css.appbar} p="2" align="center" gapX="3"> <Box display={{ md: "none" }}> <IconButton variant="soft" onClick={toggleMenu}> <HamburgerMenuIcon /> </IconButton> </Box> <Container size={{ initial: "4", md: "2", lg: "3" }}> <Flex align="center" minHeight="var(--space-6)"> <Box flexGrow="1" flexShrink="1"> <Heading size="3">{title}</Heading> </Box> {actions && ( <Flex justify="end" gapX="1"> {actions} </Flex> )} </Flex> </Container> </Flex> <Flex className={css.menu} p="2" gridRowStart="2" gridColumnStart="1" display={{ initial: isMenuOpened ? "flex" : "none", md: "flex" }} direction="column" gap="2" > <Box asChild flexGrow="1" flexShrink="1"> <ScrollArea> <NavigationMenu.Root> <NavigationMenu.Item current> <span>ホーム</span> </NavigationMenu.Item> <NavigationMenu.Group title="ユーザ管理"> <NavigationMenu.Item> <span>ユーザ追加</span> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> </ScrollArea> </Box> <Flex asChild align="center" justify="end" gap="4"> <Text size="1" color="gray"> <CopyrightNotice.ThirdParty /> <CopyrightNotice.FirstParty /> </Text> </Flex> </Flex> <Box overflowY="auto" position="relative" gridRowStart="2" gridColumnStart={{ initial: "1", md: "2" }} > {children} </Box> </Grid> ); };
-
-
-
@@ -5,35 +5,20 @@ import type { Meta, StoryObj } from "@storybook/react";import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { withIDBBackend } from "../../.storybook/decorators/withIDBBackend.tsx"; import { GetLoginUser, Login } from "../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; import * as workspaceService from "../mocks/yamori/workspace/v1/workspace_service.ts"; import * as workerService from "../mocks/yamori/worker/v1/worker_service.ts"; export default { component: Page, parameters: { layout: "fullscreen", }, decorators: [ withMockedBackend([ workspaceService.Get(), workspaceService.List(), workspaceService.Create(), workerService.List(), workerService.Get(), ]), ], decorators: [withMockedBackend([GetLoginUser(), Login()])], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Playground: Story = { decorators: [withIDBBackend(), withInmemoryRouter({ initialURL: "/" })], }; export const Root: Story = { decorators: [withInmemoryRouter({ initialURL: "/" })], };
-
@@ -41,3 +26,19 @@export const NotFound: Story = { decorators: [withInmemoryRouter({ initialURL: "/invalid" })], }; export const NotLoggedIn: Story = { decorators: [ withMockedBackend([ GetLoginUser({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), Login(), ]), withInmemoryRouter({ initialURL: "/invalid" }), ], };
-
-
-
@@ -3,30 +3,28 @@ // SPDX-License-Identifier: AGPL-3.0-onlyimport "../polyfill.ts"; import { Button, Container } from "@radix-ui/themes"; import { type FC } from "react"; import { Button } from "@radix-ui/themes"; import { type FC, useState } from "react"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import * as Empty from "../components/Empty.ts"; import { Select } from "../contexts/Router.tsx"; import * as root from "./root/page.tsx"; import * as workspace from "./:workspace/page.tsx"; import { LoggedInLayout } from "./LoggedInLayout.tsx"; export const Page: FC = () => { const [loginUser, setLoginUser] = useState<User | null>(null); if (!loginUser) { return <root.Page onLogin={(user) => void setLoginUser(user)} />; } return ( <Select routes={[ { pattern: root.pattern, children: <root.Page />, }, { pattern: workspace.pattern, children: <workspace.Page />, }, ]} routes={[]} fallback={ <Container p="3" size="2"> <LoggedInLayout title="404" user={loginUser}> <Empty.Root> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description>
-
@@ -36,7 +34,7 @@ <a href="/">トップへ</a></Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> } /> );
-