Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions frontend/src/components/Header.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ const meta = {
),
],
argTypes: {
variant: {
control: { type: 'radio' },
options: ['full', 'logoOnly'],
description: '表示バリアント(full: 全機能表示 / logoOnly: ロゴのみ)',
},
mode: {
control: { type: 'radio' },
options: ['view', 'edit'],
Expand All @@ -85,10 +90,24 @@ const meta = {
} satisfies Meta<typeof Header>;

export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof Header>;

export const LogoOnly: Story = {
args: {
variant: 'logoOnly',
},
parameters: {
docs: {
description: {
story: 'ロゴのみを表示するシンプルなヘッダー。ホーム画面などで使用されます。',
},
},
},
};

export const Default: Story = {
args: {
variant: 'full',
trip: demoTrip,
pages: demoPages,
mode: 'view',
Expand All @@ -110,6 +129,7 @@ export const Default: Story = {

export const EditMode: Story = {
args: {
variant: 'full',
trip: demoTrip,
pages: demoPages,
mode: 'edit',
Expand All @@ -131,6 +151,7 @@ export const EditMode: Story = {

export const SinglePage: Story = {
args: {
variant: 'full',
trip: {
id: 1,
title: '日帰り温泉ツアー',
Expand All @@ -155,6 +176,7 @@ export const SinglePage: Story = {

export const EmptyPages: Story = {
args: {
variant: 'full',
trip: {
id: 1,
title: '新しい旅行計画',
Expand All @@ -179,6 +201,7 @@ export const EmptyPages: Story = {

export const WithCustomClass: Story = {
args: {
variant: 'full',
trip: demoTrip,
pages: demoPages,
mode: 'view',
Expand All @@ -201,6 +224,7 @@ export const WithCustomClass: Story = {

export const ScrolledState: Story = {
args: {
variant: 'full',
trip: demoTrip,
pages: demoPages,
mode: 'view',
Expand All @@ -225,7 +249,7 @@ export const ScrolledState: Story = {
const scrollContainerRef = useRef<HTMLDivElement>(null);
return (
<div ref={scrollContainerRef} style={{ height: '200vh', overflow: 'auto' }}>
<Story args={{ ...context.args, scrollContainerRef }} />
<Story args={{ ...context.args, variant: 'full', scrollContainerRef }} />
<div style={{ padding: '2rem', marginTop: '2rem' }}>
<h2>スクロールしてヘッダーの変化を確認</h2>
<p>
Expand Down
46 changes: 40 additions & 6 deletions frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
import React, { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
import editScheduleIcon from '@/assets/icons/edit-schedule-white.svg';
import eyeSolidIcon from '@/assets/icons/eye-solid-white.svg';
import penToSquareSolidIcon from '@/assets/icons/pen-to-square-solid-white.svg';
Expand All @@ -12,20 +12,43 @@ import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';

type HeaderProps = {
type HeaderBaseProps = React.ComponentProps<'div'>;

type HeaderLogoOnlyProps = HeaderBaseProps & {
variant: 'logoOnly';
};

type HeaderFullProps = HeaderBaseProps & {
variant: 'full';
trip: Trip;
pages: Page[];
mode?: 'view' | 'edit';
selectedPageId?: Page['id'];
onSelectPage: (pageId: Page['id']) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
setMode: Dispatch<SetStateAction<'view' | 'edit'>>;
className?: string;
};

type HeaderProps = HeaderLogoOnlyProps | HeaderFullProps;

const transitionClassNames = 'duration-300 ease-in-out';

export function Header({
function HeaderLogoOnly({ className, ...props }: HeaderLogoOnlyProps) {
return (
<div
data-component='header'
className={cn(
'sticky top-0 right-0 left-0 z-10 flex w-full flex-col items-center justify-center bg-teal-50/80 px-6 py-3 backdrop-blur-sm',
className
)}
{...props}
>
<Logo size='medium' className='mx-auto' />
</div>
);
}

function HeaderFull({
pages,
trip,
mode = 'view',
Expand All @@ -34,7 +57,8 @@ export function Header({
setMode,
className,
scrollContainerRef,
}: HeaderProps) {
...props
}: Omit<HeaderFullProps, 'variant'>) {
const [isScrolled, setIsScrolled] = useState(false);
const [editPageDialogOpen, setEditPageDialogOpen] = useState(false);
const [addPageDialogOpen, setAddPageDialogOpen] = useState(false);
Expand Down Expand Up @@ -89,8 +113,11 @@ export function Header({
'sticky top-0 right-0 left-0 z-10 flex w-full flex-col justify-center gap-1 bg-teal-50/80 px-6 py-2 backdrop-blur-sm',
className
)}
{...props}
>
<Logo size={isScrolled ? 'small' : 'medium'} />
<div className='flex w-full flex-row justify-center'>
<Logo size={isScrolled ? 'small' : 'medium'} />
</div>
<div className='grid grid-cols-[1fr_auto_1fr] items-center gap-4'>
{/* 左カラム */}
<div
Expand Down Expand Up @@ -180,6 +207,13 @@ export function Header({
);
}

export function Header(props: HeaderProps) {
if (props.variant === 'logoOnly') {
return <HeaderLogoOnly {...props} />;
}
return <HeaderFull {...props} />;
}

type HeaderButtonBaseProps = React.ComponentProps<typeof Button> & {
isScrolled: boolean;
iconSrc: string;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/HeaderSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function HeaderSkeleton({ className }: HeaderSkeletonProps) {
)}
>
{/* Logo */}
<div className='pointer-events-none'>
<div className='pointer-events-none flex w-full flex-row justify-center'>
<Logo size='medium' />
</div>

Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/Logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ interface LogoProps {

export function Logo({ size = 'medium', className }: LogoProps) {
return (
<Link to='/' data-component='Logo' className={cn('flex flex-row items-center justify-center gap-1', className)}>
<Link
to='/'
data-component='Logo'
className={cn('flex w-fit flex-row items-center justify-center gap-1', className)}
>
<img
src={paperPlaneIcon}
alt=''
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/dialogs/AddTripDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useCreateTrip } from '@/hooks/useTrips';
import type { Trip } from '@/types/trip';

interface AddTripDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (trip: Trip) => void;
}

export const AddTripDialog = ({ open, onOpenChange, onCreated }: AddTripDialogProps) => {
const [title, setTitle] = useState('');
const [detail, setDetail] = useState('');
const [peopleNum, setPeopleNum] = useState<number | undefined>(undefined);
const { createTrip } = useCreateTrip();

const handleSubmit = async () => {
if (!title.trim()) {
return;
}

const newTrip = await createTrip({
title: title.trim(),
detail: detail.trim() || undefined,
peopleNum: peopleNum,
});

if (newTrip) {
onCreated?.(newTrip);
onOpenChange(false);
}
};

const handleOpenChange = (open: boolean) => {
if (!open) {
setTitle('');
setDetail('');
setPeopleNum(undefined);
}
onOpenChange(open);
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>旅程を追加</DialogTitle>
</DialogHeader>

<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='add-trip-title'>
タイトル<span className='text-red-500'>*</span>
</Label>
<Input
id='add-trip-title'
value={title}
onChange={e => setTitle(e.target.value)}
placeholder='例: 箱根温泉旅行'
/>
</div>

<div className='space-y-2'>
<Label htmlFor='add-trip-detail'>詳細</Label>
<Textarea
id='add-trip-detail'
value={detail}
onChange={e => setDetail(e.target.value)}
placeholder='旅程の詳細や目的など'
rows={3}
/>
</div>

<div className='space-y-2'>
<Label htmlFor='add-trip-people-num'>人数</Label>
<Input
id='add-trip-people-num'
type='number'
min='1'
value={peopleNum ?? ''}
onChange={e => {
const parsed = parseInt(e.target.value, 10);
setPeopleNum(!Number.isNaN(parsed) && parsed >= 1 ? parsed : undefined);
}}
placeholder='例: 4'
/>
</div>
</div>

<DialogFooter>
<Button variant='outline' onClick={() => handleOpenChange(false)}>
キャンセル
</Button>
<Button onClick={handleSubmit} disabled={!title.trim()}>
追加
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
33 changes: 16 additions & 17 deletions frontend/src/hooks/useTrips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import useSWR, { useSWRConfig } from 'swr';
import useSWRMutation from 'swr/mutation';
import z from 'zod';
import { apiClient, fetcher } from '@/lib/apiClient';
import { AppRequestTripSchema, AppResponseTripSchema, type Trip, TripSchema } from '@/types/trip';
import {
AppRequestTripMutationSchema,
AppResponseTripSchema,
type Trip,
type TripMutation,
TripMutationSchema,
} from '@/types/trip';

const TRIPS_BASE_PATH = '/trips';

Expand Down Expand Up @@ -80,19 +86,16 @@ export const useTrip = (id: Trip['id'] | null) => {
};
};

type CreateTripArg = Omit<Trip, 'id'>;
const CreateTripSchema = TripSchema.omit({ id: true });
type CreateTripArg = TripMutation;

/**
* 新しいTripを作成する
*/
export const useCreateTrip = () => {
const createTrip = useCallback(async (url: string, { arg: tripData }: { arg: CreateTripArg }) => {
CreateTripSchema.parse(tripData);
// アプリ層→API層に変換してからバリデーション・送信
const apiData = AppRequestTripSchema.parse({ ...tripData, id: 0 }); // idは仮値
const { id: _, ...payload } = apiData; // idを除外
const response = await apiClient.post(url, payload);
TripMutationSchema.parse(tripData);
const apiData = AppRequestTripMutationSchema.parse(tripData);
const response = await apiClient.post(url, apiData);
return AppResponseTripSchema.parse(response.data);
}, []);

Expand All @@ -109,8 +112,7 @@ export const useCreateTrip = () => {
};
};

type UpdateTripArg = { id: Trip['id']; data: Omit<Trip, 'id'> };
const UpdateTripSchema = TripSchema.omit({ id: true });
type UpdateTripArg = { id: Trip['id']; data: TripMutation };

/**
* Tripを更新するためのフック
Expand All @@ -120,11 +122,9 @@ export const useUpdateTrip = () => {

const updateTripFetcher = useCallback(async (_key: string | null, { arg }: { arg: UpdateTripArg }) => {
const { id, data } = arg;
UpdateTripSchema.parse(data);
// アプリ層→API層に変換してからバリデーション・送信
const apiData = AppRequestTripSchema.parse({ ...data, id });
const { id: _, ...payload } = apiData; // idを除外(URLパスで指定)
const response = await apiClient.put(`${TRIPS_BASE_PATH}/${id}`, payload);
TripMutationSchema.parse(data);
const apiData = AppRequestTripMutationSchema.parse(data);
const response = await apiClient.put(`${TRIPS_BASE_PATH}/${id}`, apiData);
return AppResponseTripSchema.parse(response.data);
}, []);

Expand Down Expand Up @@ -159,7 +159,6 @@ export const useUpdateTrip = () => {
};

type DeleteTripArg = Trip['id'];
const DeleteTripSchema = TripSchema.shape.id;

/**
* Tripを削除する
Expand All @@ -168,7 +167,7 @@ export const useDeleteTrip = () => {
const { mutate } = useSWRConfig();

const deleteTripFetcher = useCallback(async (_: string | null, { arg: id }: { arg: DeleteTripArg }) => {
DeleteTripSchema.parse(id);
z.number().parse(id);
await apiClient.delete(`${TRIPS_BASE_PATH}/${id}`);
return undefined;
}, []);
Expand Down
Loading