This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AmazonData } from './types';
|
||||
|
||||
function StarRating({ rating }: { rating: number }) {
|
||||
@@ -10,6 +11,7 @@ function StarRating({ rating }: { rating: number }) {
|
||||
}
|
||||
|
||||
export function AmazonProductsCard({ data, onExpand }: { data: AmazonData; onExpand: () => void }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, products } = data;
|
||||
|
||||
return (
|
||||
@@ -17,8 +19,8 @@ export function AmazonProductsCard({ data, onExpand }: { data: AmazonData; onExp
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm">🛒</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>Amazon 検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{products.length}件</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.amazon', { query })}</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: products.length })}</span>
|
||||
</div>
|
||||
|
||||
{/* Horizontal scroll cards */}
|
||||
@@ -59,7 +61,7 @@ export function AmazonProductsCard({ data, onExpand }: { data: AmazonData; onExp
|
||||
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 詳細を表示
|
||||
▼ {t('expand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AmazonData } from './types';
|
||||
|
||||
function StarRating({ rating, reviewCount }: { rating: number; reviewCount?: number }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const full = Math.floor(rating);
|
||||
const half = rating - full >= 0.5;
|
||||
const stars: string[] = [];
|
||||
@@ -9,18 +11,19 @@ function StarRating({ rating, reviewCount }: { rating: number; reviewCount?: num
|
||||
return (
|
||||
<span className="text-amber-400 text-sm">
|
||||
{stars.join('')} {rating.toFixed(1)}
|
||||
{reviewCount != null && <span className="text-slate-400 text-xs ml-1">({reviewCount.toLocaleString()}件)</span>}
|
||||
{reviewCount != null && <span className="text-slate-400 text-xs ml-1">{t('reviewCount', { count: reviewCount.toLocaleString() })}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmazonProductsDetail({ data }: { data: AmazonData }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, products } = data;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
🛒 Amazon 検索結果: 「{query}」
|
||||
🛒 {t('searchResults.amazon', { query })}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -60,7 +63,7 @@ export function AmazonProductsDetail({ data }: { data: AmazonData }) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-amber-400 hover:bg-amber-500 text-slate-900 text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
Amazon で見る
|
||||
{t('viewOn.amazon')}
|
||||
</a>
|
||||
<a
|
||||
href={p.keepaDetailUrl}
|
||||
@@ -68,7 +71,7 @@ export function AmazonProductsDetail({ data }: { data: AmazonData }) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
Keepa で見る
|
||||
{t('viewOn.keepa')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,10 +79,10 @@ export function AmazonProductsDetail({ data }: { data: AmazonData }) {
|
||||
|
||||
{/* Keepa price graph */}
|
||||
<div className="mt-4 bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-2">📈 価格推移 (Keepa)</div>
|
||||
<div className="text-xs text-slate-500 mb-2">📈 {t('priceHistory')}</div>
|
||||
<img
|
||||
src={p.keepaGraphUrl}
|
||||
alt={`${p.title} 価格推移`}
|
||||
alt={t('priceHistoryAlt', { title: p.title })}
|
||||
className="w-full rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
interface EmbedModalProps {
|
||||
@@ -9,6 +10,7 @@ interface EmbedModalProps {
|
||||
}
|
||||
|
||||
export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
|
||||
const { t } = useTranslation('embed');
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
@@ -56,7 +58,7 @@ export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
|
||||
rounded-full text-slate-500 hover:text-slate-700
|
||||
transition-colors cursor-pointer border-none text-lg
|
||||
"
|
||||
aria-label="閉じる"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MapData } from './types';
|
||||
|
||||
export function MapPlacesCard({ data, onExpand }: { data: MapData; onExpand: () => void }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, places } = data;
|
||||
|
||||
return (
|
||||
@@ -8,8 +10,8 @@ export function MapPlacesCard({ data, onExpand }: { data: MapData; onExpand: ()
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm">📍</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>地図検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{places.length}件</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.map', { query })}</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: places.length })}</span>
|
||||
</div>
|
||||
|
||||
{/* Place list */}
|
||||
@@ -35,7 +37,7 @@ export function MapPlacesCard({ data, onExpand }: { data: MapData; onExpand: ()
|
||||
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 地図で表示
|
||||
▼ {t('expandMap')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MapData } from './types';
|
||||
|
||||
// Leaflet CDN を動的にロードする
|
||||
@@ -33,6 +34,7 @@ function loadLeaflet(): Promise<void> {
|
||||
declare const L: typeof import('leaflet');
|
||||
|
||||
export function MapPlacesDetail({ data }: { data: MapData }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, places } = data;
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<import('leaflet').Map | null>(null);
|
||||
@@ -85,7 +87,7 @@ export function MapPlacesDetail({ data }: { data: MapData }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
📍 地図検索結果: 「{query}」
|
||||
📍 {t('searchResults.map', { query })}
|
||||
</h2>
|
||||
|
||||
{/* Leaflet map */}
|
||||
@@ -115,7 +117,7 @@ export function MapPlacesDetail({ data }: { data: MapData }) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
OpenStreetMap で開く
|
||||
{t('viewOn.osm')}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { XPostData } from './types';
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
@@ -7,6 +8,7 @@ function formatNumber(n: number): string {
|
||||
}
|
||||
|
||||
export function XPostsCard({ data, onExpand }: { data: XPostData; onExpand: () => void }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, posts } = data;
|
||||
|
||||
return (
|
||||
@@ -14,8 +16,8 @@ export function XPostsCard({ data, onExpand }: { data: XPostData; onExpand: () =
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-bold text-slate-800" style={{ fontSize: 14 }}>𝕏</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>X 検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{posts.length}件</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.x', { query })}</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: posts.length })}</span>
|
||||
</div>
|
||||
|
||||
{/* Post list */}
|
||||
@@ -58,7 +60,7 @@ export function XPostsCard({ data, onExpand }: { data: XPostData; onExpand: () =
|
||||
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 詳細を表示
|
||||
▼ {t('expand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { XPostData } from './types';
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
@@ -18,12 +19,13 @@ function formatDate(iso: string): string {
|
||||
}
|
||||
|
||||
export function XPostsDetail({ data }: { data: XPostData }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, posts } = data;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
<span style={{ fontSize: 20 }}>𝕏</span> X 検索結果: 「{query}」
|
||||
<span style={{ fontSize: 20 }}>𝕏</span> {t('searchResults.x', { query })}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -54,10 +56,10 @@ export function XPostsDetail({ data }: { data: XPostData }) {
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="flex gap-4 text-slate-400 mb-3" style={{ fontSize: 12 }}>
|
||||
<span title="いいね">♥ {formatNumber(p.likes)}</span>
|
||||
<span title="リポスト">🔁 {formatNumber(p.retweets)}</span>
|
||||
<span title="返信">💬 {formatNumber(p.replies)}</span>
|
||||
<span title="表示">👁 {formatNumber(p.views)}</span>
|
||||
<span title={t('metrics.likes')}>♥ {formatNumber(p.likes)}</span>
|
||||
<span title={t('metrics.retweets')}>🔁 {formatNumber(p.retweets)}</span>
|
||||
<span title={t('metrics.replies')}>💬 {formatNumber(p.replies)}</span>
|
||||
<span title={t('metrics.views')}>👁 {formatNumber(p.views)}</span>
|
||||
</div>
|
||||
|
||||
{/* Link */}
|
||||
@@ -67,7 +69,7 @@ export function XPostsDetail({ data }: { data: XPostData }) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-900 hover:bg-slate-700 text-white text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
X で見る
|
||||
{t('viewOn.x')}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { YouTubeData } from './types';
|
||||
|
||||
export function YouTubeVideosCard({ data, onExpand }: { data: YouTubeData; onExpand: () => void }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, videos } = data;
|
||||
|
||||
return (
|
||||
@@ -8,8 +10,8 @@ export function YouTubeVideosCard({ data, onExpand }: { data: YouTubeData; onExp
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm">▶</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>YouTube 検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{videos.length}件</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.youtube', { query })}</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: videos.length })}</span>
|
||||
</div>
|
||||
|
||||
{/* Horizontal scroll thumbnails */}
|
||||
@@ -62,7 +64,7 @@ export function YouTubeVideosCard({ data, onExpand }: { data: YouTubeData; onExp
|
||||
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 詳細を表示
|
||||
▼ {t('expand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { YouTubeData } from './types';
|
||||
|
||||
export function YouTubeVideosDetail({ data }: { data: YouTubeData }) {
|
||||
const { t } = useTranslation('embed');
|
||||
const { query, videos } = data;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
▶ YouTube 検索結果: 「{query}」
|
||||
▶ {t('searchResults.youtube', { query })}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -55,7 +57,7 @@ export function YouTubeVideosDetail({ data }: { data: YouTubeData }) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
YouTube で見る
|
||||
{t('viewOn.youtube')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user