Skip to content
Draft
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
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ TEST_POSTGRES_USER=encrypted:BK9zSnMHMV9qOSMhWXL3/CCW2kba6YuK4FWyedqmymvbeKS5Io1
TEST_POSTGRES_PASSWORD=encrypted:BO2T9M18B6ZRGXbkrARGI5Y/39VAJTKk6bh6U26awqLOI3oo64U25FUJnQZDt41hQx1dDOEFw5VQn8xn9jEMohgqftc38/5J1163A2SUxgpwqX+E+LIMVpAdF/5DdwY6kgZmHvNqSoojLon8JjGi/xYzvYZYp14u
GOOGLE_MAPS_API_KEY="encrypted:BPEXj1LGaBGLofjSbq1f+a2g2UY0a52eKoE0EigAmBR9hEqhUBII3uxOoePs8emRckKJTOsVQhvEF4R6ljPgjRTbTe4CHJOtePtrrqZdfeXM6j+ihQYPU33Xj7HMWJ4nJqwC3qlyHCXfsNyUqtWqt+ogJ8BcxdRXalPZ29hc08oLC1Cz5kuzkA=="
COOKIE_SECRET_KEY="encrypted:BGPsrFyF8pfvWalH7FTVOsk/YF2IE1Rij5bU4Cd+L1yfE30CuH4eAW6BtNuI0z7CSsGprh40Ff1CFLjYUKDoiB2YLh3V958LfCrMBTepT+1c541LPisVXIJBaYELuAKLYisv2ea56cAoaEgplV1o0IX8y9HRd0Aee7I57UG0Akl6YOVxKjCgL9dNix5K5BlLbmETcxQiIy4SueaZ5DrQlJo="
GEMINI_API_KEY="encrypted:BP2mtWbB8yFLXhkV2pA+pwTnTRYVRlPPIt7yaV8rzLOaDz0dbUjbJj2o3AOesuzus31KyKfZbES7pH4KK4EzQ0UrVy4XDFv21rsBAAPpBBtntjuEFzsDyVAEWtzCnjSj1bTuKPh5lPbCL5iud6Sr1w3QJpJetktt+Ev+JESbDEe0yu3nO1N5uQ=="
1 change: 1 addition & 0 deletions .env.prod
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ VITE_GOOGLE_MAPS_API_KEY="encrypted:BL51xaluiOOL5lSdrtOgqh5DUURc4YNXXtstGJisVDh6
# ==================================
# バックエンド用 (server/.env)
# ==================================
GEMINI_API_KEY="encrypted:BNVJDvide1SMKfMAgfG5oFh/pFHEqiRbziDIaB9JxK3P5e8C3aNsFXp7MR4pasot/VoOq5xXHTDGg/zvfyjxyvOwJNVW3b6c1bafSfvw4Nu1K4vkgodKYuYiFpEvogXilIimcZmvJ4E9jZtAZHAG7Mem1GdtUXevTfuTss2K7r5cEt3iaWbFyg=="
1 change: 1 addition & 0 deletions .env.stg
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ VITE_GOOGLE_MAPS_API_KEY="encrypted:BDktow+Fasf1jaW5SImOYFQ4loQ/Y35aVFvW4WF2eRZo
# ==================================
# バックエンド用 (server/.env)
# ==================================
GEMINI_API_KEY="encrypted:BE7OVmuLqqHT41hGaPrculoSaKO3Hn+DMBRKsLPldxMK/RVEl+hm2W4vHbITzNS0MatZ9CMBTUF2lQNNpWISRF8S601UvQo4uEJSQc9rRTNpLNyg2J3A256nF773JSXZv2WKuMA22VtOFQTnGFbgrDfmU5t26/xdNv8IcMu6kyRndTl3+90+aw=="
92 changes: 92 additions & 0 deletions frontend/src/components/tripUrl/TripUrlFormatSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useFormatTripUrl } from '@/hooks/useTripUrls';
import { TRIP_URL_FORMAT_INTENT_MAX_LENGTH, TRIP_URL_FORMAT_SOURCE_MAX_LENGTH } from '@/types';

interface TripUrlFormatSectionProps {
tripId: number;
/** ダイアログの開閉状態。true → false → true で内部入力をリセット */
open: boolean;
/** 整形成功時に呼ばれるコールバック。markdown を memo の末尾に追記する想定 */
onAppend: (markdown: string) => void;
}

export const TripUrlFormatSection = ({ tripId, open, onAppend }: TripUrlFormatSectionProps) => {
const [sourceText, setSourceText] = useState('');
const [intent, setIntent] = useState('');
const { formatTripUrl, isFormatting } = useFormatTripUrl(tripId);

// ダイアログが開き直されたタイミングで入力をリセット
useEffect(() => {
if (open) {
setSourceText('');
setIntent('');
}
}, [open]);

const handleFormat = async () => {
const trimmedSource = sourceText.trim();
if (!trimmedSource) {
toast.error('整形対象テキストを入力してください');
return;
}
const result = await formatTripUrl({
sourceText,
intent: intent.trim() || null,
});
if (result?.markdown) {
onAppend(result.markdown);
// 連続整形しやすいように整形対象だけクリア(指示は残す)
setSourceText('');
toast.success('AI 整形をメモに追記しました');
}
};

return (
<div className='space-y-3 rounded-md border border-muted-foreground/40 border-dashed p-3'>
<div className='flex items-center justify-between'>
<p className='font-medium text-12px text-muted-foreground sm:text-14px'>AI で整形(任意)</p>
<p className='text-10px text-muted-foreground sm:text-12px'>結果はメモ末尾に追記されます</p>
</div>

<div className='space-y-1'>
<Label htmlFor='trip-url-format-source'>整形対象テキスト</Label>
<Textarea
id='trip-url-format-source'
value={sourceText}
onChange={e => setSourceText(e.target.value)}
placeholder='ページから料金表など整形したい部分をコピペ'
rows={4}
maxLength={TRIP_URL_FORMAT_SOURCE_MAX_LENGTH}
/>
</div>

<div className='space-y-1'>
<Label htmlFor='trip-url-format-intent'>指示(任意)</Label>
<Input
id='trip-url-format-intent'
value={intent}
onChange={e => setIntent(e.target.value)}
placeholder='例: 朝食付きと素泊まりで価格表に / 空欄ならサマリ'
maxLength={TRIP_URL_FORMAT_INTENT_MAX_LENGTH}
/>
</div>

<div className='flex justify-end'>
<Button
type='button'
variant='secondary'
onClick={handleFormat}
loading={isFormatting}
disabled={!sourceText.trim() || isFormatting}
>
AI で整形
</Button>
</div>
</div>
);
};
78 changes: 78 additions & 0 deletions frontend/src/components/tripUrl/TripUrlList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { AddTripUrlDialog, EditTripUrlDialog } from '@/dialogs';
import { useTripUrls } from '@/hooks/useTripUrls';
import type { TripUrl } from '@/types';
import { TripUrlListItem } from './TripUrlListItem';

interface TripUrlListProps {
tripId: number;
}

export const TripUrlList = ({ tripId }: TripUrlListProps) => {
const { tripUrls, isLoading } = useTripUrls(tripId);
const [addOpen, setAddOpen] = useState(false);
const [editingId, setEditingId] = useState<TripUrl['id'] | null>(null);

const editingTripUrl = editingId != null ? (tripUrls?.find(u => u.id === editingId) ?? null) : null;

const count = tripUrls?.length ?? 0;

return (
<>
<Accordion type='single' collapsible className='w-full max-w-3xl rounded-lg border bg-white'>
<AccordionItem value='trip-urls' className='px-3'>
<AccordionTrigger>
<span className='flex items-center gap-2'>
URL ストック
{count > 0 && (
<span className='rounded-full bg-muted px-2 py-0.5 text-12px text-muted-foreground'>{count}</span>
)}
</span>
</AccordionTrigger>
<AccordionContent>
<div className='flex flex-col gap-2 pb-2'>
<div className='flex justify-end'>
<Button type='button' size='sm' variant='outline' onClick={() => setAddOpen(true)}>
<Plus className='h-4 w-4' />
追加
</Button>
</div>

{isLoading && (
<>
<Skeleton className='h-20 w-full' />
<Skeleton className='h-20 w-full' />
</>
)}

{!isLoading && count === 0 && (
<p className='py-4 text-center text-14px text-muted-foreground'>まだ URL がストックされていません</p>
)}

{!isLoading &&
tripUrls?.map(tripUrl => (
<TripUrlListItem key={tripUrl.id} tripUrl={tripUrl} onEdit={u => setEditingId(u.id)} />
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>

<AddTripUrlDialog open={addOpen} onOpenChange={setAddOpen} tripId={tripId} />

{editingTripUrl && (
<EditTripUrlDialog
open={editingId != null}
onOpenChange={next => {
if (!next) setEditingId(null);
}}
tripUrl={editingTripUrl}
/>
)}
</>
);
};
60 changes: 60 additions & 0 deletions frontend/src/components/tripUrl/TripUrlListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ExternalLink, Globe, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MarkdownViewer } from '@/components/ui/markdown';
import { getDomain } from '@/lib/utils';
import type { TripUrl } from '@/types';

interface TripUrlListItemProps {
tripUrl: TripUrl;
onEdit: (tripUrl: TripUrl) => void;
}

export const TripUrlListItem = ({ tripUrl, onEdit }: TripUrlListItemProps) => {
const displayTitle = tripUrl.title?.trim() || tripUrl.url;

return (
<div className='flex gap-3 rounded-md border bg-white p-3'>
{tripUrl.thumbnailUrl ? (
<img
src={tripUrl.thumbnailUrl}
alt=''
className='h-16 w-16 shrink-0 rounded-md border object-cover sm:h-20 sm:w-20'
loading='lazy'
/>
) : (
<div className='flex h-16 w-16 shrink-0 items-center justify-center rounded-md border bg-muted text-muted-foreground sm:h-20 sm:w-20'>
<Globe className='h-6 w-6' />
</div>
)}

<div className='flex min-w-0 flex-1 flex-col gap-1'>
<a
href={tripUrl.url}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 font-semibold text-14px sm:text-16px'
>
<span className='truncate'>{displayTitle}</span>
<ExternalLink className='h-3 w-3 shrink-0 text-muted-foreground' />
</a>
<span className='truncate text-12px text-muted-foreground sm:text-14px'>{getDomain(tripUrl.url)}</span>
{tripUrl.memo && (
<div className='mt-1 line-clamp-3 text-12px text-foreground sm:text-14px'>
<MarkdownViewer content={tripUrl.memo} />
</div>
)}
</div>

<Button
type='button'
variant='ghost'
size='icon'
aria-label='編集'
onClick={() => onEdit(tripUrl)}
className='shrink-0'
>
<Pencil className='h-4 w-4' />
</Button>
</div>
);
};
3 changes: 3 additions & 0 deletions frontend/src/components/tripUrl/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { TripUrlFormatSection } from './TripUrlFormatSection';
export { TripUrlList } from './TripUrlList';
export { TripUrlListItem } from './TripUrlListItem';
Loading
Loading