1
0

Migrate from Vue2 to React with modern tech stack

- Replace Vue2 + Vue CLI with Vite + React 18 + TypeScript
- Add Tailwind CSS for efficient styling
- Implement clean component architecture:
  - Split 1000+ line Vue component into focused React components
  - Add proper type safety with TypeScript
  - Use React Query for efficient data fetching
- Update GitHub Actions for React build pipeline
- Maintain existing functionality and design
- Support Node.js 23 with .nvmrc

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-18 13:44:54 +09:00
parent 980e9c1259
commit e7f39a1894
23 changed files with 1064 additions and 22 deletions

View File

@@ -0,0 +1,140 @@
import { useState } from 'react';
import type { Card, User } from '../../types';
import { SKILL_ICONS, CARD_STATUS_COLORS } from '../../utils/constants';
interface CardGridProps {
cards: Card[];
user?: User;
}
export default function CardGrid({ cards }: CardGridProps) {
const [showInfo, setShowInfo] = useState(false);
const [sortBy, setSortBy] = useState<'time' | 'cp' | 'card'>('time');
const sortedCards = [...cards].sort((a, b) => {
switch (sortBy) {
case 'cp':
return b.cp - a.cp;
case 'card':
return b.card - a.card;
default:
return b.id - a.id; // newest first
}
});
const getSkillIcon = (skill: Card['skill']) => {
const iconClass = SKILL_ICONS[skill];
if (skill === 'model') {
return <i className="fa-solid fa-cube"></i>;
}
return <span className={iconClass}></span>;
};
const getCardImage = (card: Card) => {
if (card.author === 'yui') {
return `/card/card_origin2_${card.card}.webp`;
} else if (card.author) {
return `/card/card_origin_${card.card}.webp`;
}
return `/card/card_${card.card}.webp`;
};
const isSpecialStatus = (status: Card['status']) => {
return status !== 'normal';
};
return (
<div>
{/* Control Buttons */}
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => setSortBy('time')}
className={`btn ${sortBy === 'time' ? 'bg-secondary' : ''}`}
>
new
</button>
<button
onClick={() => setSortBy('cp')}
className={`btn ${sortBy === 'cp' ? 'bg-secondary' : ''}`}
>
cp
</button>
<button
onClick={() => setSortBy('card')}
className={`btn ${sortBy === 'card' ? 'bg-secondary' : ''}`}
>
card
</button>
<button
onClick={() => setShowInfo(!showInfo)}
className={`btn ${showInfo ? 'bg-secondary' : ''}`}
>
info
</button>
</div>
{/* Cards Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{sortedCards.map((card) => (
<div key={card.id} className="text-center">
{isSpecialStatus(card.status) ? (
<div className="relative mb-2">
<div className="card-wrapper">
<div className="card-reflection">
<img
src={getCardImage(card)}
alt={`Card ${card.card}`}
className="w-full rounded-lg"
/>
</div>
<div className={`card-status ${CARD_STATUS_COLORS[card.status]}`}></div>
</div>
</div>
) : (
<div className="mb-2">
{card.card === 43 ? (
<a href="/book/1/ZGlkOnBsYzo0aHFqZm43bTZuNWhubzNkb2FtdWhnZWY/index.html">
<img
src={getCardImage(card)}
alt={`Card ${card.card}`}
className="w-full rounded-lg hover:shadow-lg transition-shadow"
/>
</a>
) : (
<div className={card.author ? 'relative' : ''}>
<img
src={getCardImage(card)}
alt={`Card ${card.card}`}
className="w-full rounded-lg"
/>
{card.author && (
<div className="absolute bottom-1 left-1 text-xs bg-black/70 text-white px-1 rounded">
@{card.author}
</div>
)}
</div>
)}
</div>
)}
<div className="text-sm">
<div className="flex items-center justify-center gap-1 mb-1">
{getSkillIcon(card.skill)}
<span>{card.cp}</span>
</div>
{showInfo && (
<div className="text-xs text-gray-600">
<div>ID {card.card}</div>
<div>CID {card.id}</div>
<div>{card.skill}</div>
<div> {card.status}</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useLocation } from 'react-router-dom';
interface NavigationProps {
username?: string;
}
export default function Navigation({ username: _username }: NavigationProps) {
const location = useLocation();
const loc = location.pathname.split('/').slice(-1)[0];
const getDisplayText = () => {
switch (loc) {
case 'te':
return '@yui.syui.ai /ten';
case 'pr':
return '@yui.syui.ai /fav 1234567';
case 'docs':
case 'en':
return '@yui.syui.ai /help';
case 'di':
return '@yui.syui.ai /did';
case 'svn':
return '@yui.syui.ai /ten pay 7';
default:
return '@yui.syui.ai /card';
}
};
return (
<div className="bg-dark text-light mb-8">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center">
<a href="/" className="text-accent text-2xl px-4">
<span className="icon-ai"></span>
</a>
<code className="bg-dark p-0">
<a
href="https://bsky.app/profile/yui.syui.ai"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-primary"
>
{getDisplayText()}
</a>
</code>
</div>
<div className="flex gap-2">
<form className="flex gap-2">
<input
type="text"
placeholder="user"
className="px-2 py-1 text-black rounded"
/>
<input
type="submit"
value="Go"
className="px-3 py-1 bg-primary text-white rounded hover:bg-secondary cursor-pointer"
/>
</form>
<form className="flex gap-2">
<input
type="text"
placeholder="id"
className="px-2 py-1 text-black rounded"
/>
<input
type="submit"
value="Go"
className="px-3 py-1 bg-primary text-white rounded hover:bg-secondary cursor-pointer"
/>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { useQuery } from '@tanstack/react-query';
import Navigation from '../common/Navigation';
import { fetchFanarts } from '../../utils/api';
interface DocsPageProps {
isEnglish?: boolean;
page?: 'did' | 'vr' | 'aiten' | 'cards' | 'seven' | 'fanart' | 'photo' | 'favorite';
}
export default function DocsPage({ isEnglish = false, page }: DocsPageProps) {
const { data: fanarts } = useQuery({
queryKey: ['fanarts'],
queryFn: fetchFanarts,
enabled: page === 'fanart',
});
const renderContent = () => {
if (page === 'did') {
return (
<div className="prose max-w-none">
<h3>DID</h3>
<blockquote className="bg-primary text-white p-8 text-center">
<p>
Decentralized identifiers (DIDs) are a new type of identifier that enables verifiable,
decentralized digital identity. A DID refers to any subject (e.g., a person, organization,
thing, data model, abstract entity, etc.) as determined by the controller of the DID.
</p>
</blockquote>
<p>
<a href="https://www.w3.org/TR/did-core/" target="_blank" rel="noopener noreferrer">
https://www.w3.org/TR/did-core/
</a>
</p>
</div>
);
}
if (page === 'fanart' && fanarts) {
return (
<div>
<p><code>/fa &lt;share-url&gt; &lt;img-url&gt;</code></p>
<div className="grid gap-6">
{fanarts.data.filter(item => !item.delete).map((item, index) => (
<div key={index} className="bg-white p-6 rounded-lg">
<p>
<a href={item.link} target="_blank" rel="noopener noreferrer">
<img src={item.img} alt="" className="max-w-full h-auto" />
</a>
</p>
<p>
author: <a href={`https://bsky.app/profile/${item.author}`} target="_blank" rel="noopener noreferrer">
{item.author}
</a>
</p>
</div>
))}
</div>
</div>
);
}
// Default documentation content
return (
<div className="prose max-w-none">
<p>{isEnglish ? 'Cards can be drawn once a day' : 'カードは1日に1回、引くことができます'}</p>
<p>{isEnglish ? 'Card emission rates are as follows' : 'カードの排出率は以下のとおりです'}</p>
<table className="w-full border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2">status</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-300 p-2">normal</td>
<td className="border border-gray-300 p-2">rare</td>
<td className="border border-gray-300 p-2">super</td>
</tr>
<tr>
<td className="border border-gray-300 p-2">90%</td>
<td className="border border-gray-300 p-2">9%</td>
<td className="border border-gray-300 p-2">1%</td>
</tr>
</tbody>
</table>
<h3>{isEnglish ? 'Battle' : '対戦について'}</h3>
<p><code>@yui.syui.ai /card -b</code></p>
<p>{isEnglish ? 'Random match, one of the top 3 cards on hand will be chosen at random' : 'ランダムマッチ、手持ちの上位3枚のうち1枚がランダムで選ばれます'}</p>
<h3>Mastodon</h3>
<p>
<code>
<a href="https://mstdn.syui.ai/@yui" target="_blank" rel="noopener noreferrer">
@yui@syui.ai
</a> /card
</code>
</p>
</div>
);
};
return (
<div className="min-h-screen">
<Navigation />
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg p-8">
{renderContent()}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Navigation from '../common/Navigation';
import { fetchUsers } from '../../utils/api';
export default function HomePage() {
const [didEnable, setDidEnable] = useState(false);
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetchUsers(),
});
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-xl">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen">
<Navigation />
<div className="max-w-6xl mx-auto px-4">
<div className="mb-8">
<a href="/docs" className="btn">help</a>
<a href="/en" className="btn ml-2">en</a>
<a href="/pr" className="btn ml-2">fav</a>
<a href="/te" className="btn ml-2">ten</a>
<button
onClick={() => setDidEnable(!didEnable)}
className="btn ml-2"
>
did
</button>
<a href="/c" className="btn ml-2">all</a>
<a href="/svn" className="btn ml-2">seven</a>
</div>
{users && (
<div className="bg-white rounded-lg p-6">
<div className="grid gap-4">
{users.data.map((user) => (
<div key={user.id} className="border-b pb-4 last:border-b-0">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-4">
{user.model && (
<button className="text-lg">
<i className="fa-solid fa-cube"></i>
</button>
)}
{user.fav !== '0' && (
<button className="text-lg"></button>
)}
{user.username === 'ai' && (
<a
href={`https://git.syui.ai/${user.username}`}
target="_blank"
rel="noopener noreferrer"
className="text-lg"
>
<i className="fa-brands fa-git-alt"></i>
</a>
)}
<button className="btn-sm">ID {user.id}</button>
</div>
</div>
<div className="text-lg">
<a href={`/${user.username}`} className="font-semibold">
{user.username}
</a>
</div>
{didEnable && user.did && (
<div className="mt-2">
{user.did.includes('did:') ? (
<button className="btn-sm">
<a
href={`https://plc.directory/${user.did}/log`}
target="_blank"
rel="noopener noreferrer"
className="text-light"
>
{user.did}
</a>
</button>
) : user.did.includes('http') ? (
<button className="btn-sm">
<a
href={user.did}
target="_blank"
rel="noopener noreferrer"
className="text-light"
>
{user.did}
</a>
</button>
) : null}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useQuery } from '@tanstack/react-query';
import Navigation from '../common/Navigation';
import { fetchCardData } from '../../utils/api';
export default function OwnerPage() {
const { data: cardData, isLoading } = useQuery({
queryKey: ['cardData'],
queryFn: fetchCardData,
});
const specialCards = [22, 25, 26, 27, 29, 33, 36, 39, 40, 41, 44, 45];
if (isLoading) {
return (
<div className="min-h-screen">
<Navigation />
<div className="flex items-center justify-center py-8">
<div className="text-xl">Loading...</div>
</div>
</div>
);
}
return (
<div className="min-h-screen">
<Navigation />
<div className="max-w-6xl mx-auto px-4">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{cardData?.data.map((card) => {
const shouldShow = card.id < 15 || specialCards.includes(card.id);
if (!shouldShow) return null;
const isBlackCard = [22, 27, 29, 33, 36].includes(card.id) && card.owner === 'none';
return (
<div key={card.id} className="bg-white rounded-lg p-6">
<button
id={card.id.toString()}
className="btn mb-4"
>
card : {card.id} / {card.h}
</button>
<div className={`mb-4 ${isBlackCard ? 'bg-black p-4 rounded' : ''}`}>
<img
src={`/card/card_${card.id}.webp`}
alt={`Card ${card.id}`}
className={`w-48 mx-auto ${isBlackCard ? 'opacity-70' : ''}`}
/>
</div>
<p className="text-center">
{card.owner ? (
<>
owner : <a href={`/${card.owner}`} className="text-primary hover:text-secondary">
{card.owner}
</a>
</>
) : (
<>owner : none</>
)}
</p>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Navigation from '../common/Navigation';
import CardGrid from '../card/CardGrid';
import UserProfile from '../user/UserProfile';
import { fetchUsers, fetchUserCards } from '../../utils/api';
export default function UserPage() {
const { username } = useParams<{ username: string }>();
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => fetchUsers(),
});
const user = users?.data.find(u => u.username === username);
const { data: cards, isLoading: cardsLoading } = useQuery({
queryKey: ['userCards', user?.id],
queryFn: () => fetchUserCards(user!.id),
enabled: !!user?.id,
});
if (!user) {
return (
<div className="min-h-screen">
<Navigation />
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="text-center text-xl">User not found</div>
</div>
</div>
);
}
return (
<div className="min-h-screen">
<Navigation username={username} />
<div className="max-w-6xl mx-auto px-4">
<UserProfile user={user} cards={cards?.data || []} />
{cardsLoading ? (
<div className="text-center py-8">Loading cards...</div>
) : (
<CardGrid cards={cards?.data || []} user={user} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import type { User, Card } from '../../types';
interface UserProfileProps {
user: User;
cards: Card[];
}
export default function UserProfile({ user, cards }: UserProfileProps) {
return (
<div className="bg-white rounded-lg p-6 mb-8">
<h3 className="text-2xl font-bold mb-4 flex items-center gap-2">
<span>{user.username}</span>
{/* Badges */}
<span className="text-yellow-500">
<span className="icon-ai"></span>
</span>
{cards.find(c => c.card === 65) && (
<span className="text-yellow-400">
<span className="icon-ai"></span>
</span>
)}
{user.aiten >= 70000000 && (
<span className="text-yellow-500">
<span className="icon-power"></span>
</span>
)}
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<strong>ID:</strong> {user.id}
</div>
<div className="flex items-center gap-1">
<span className="icon-power"></span>
{user.aiten?.toLocaleString()}
</div>
<div className="flex items-center gap-1">
<i className="fa-solid fa-cube"></i>
{cards.filter(c => c.skill === 'lost').length}
</div>
<div className="flex items-center gap-1">
<span className="icon-ai"></span>
{cards.filter(c => c.card >= 96 && c.card <= 121).length}
</div>
{user.planet && (
<div className="flex items-center gap-1">
<i className="fa-solid fa-earth-americas"></i>
{user.planet}
</div>
)}
</div>
{/* Badge Images */}
<div className="flex gap-2 mt-4">
{cards.find(c => c.card === 18) && (
<img src="/card/badge_1.png" alt="Badge 1" className="w-6 h-6" />
)}
{cards.find(c => c.card === 41) && (
<img src="/card/badge_2.png" alt="Badge 2" className="w-6 h-6" />
)}
{cards.find(c => c.card === 45) && (
<img src="/card/badge_3.png" alt="Badge 3" className="w-6 h-6" />
)}
{cards.find(c => c.card === 75) && (
<img src="/card/badge_4.png" alt="Badge 4" className="w-6 h-6" />
)}
{cards.find(c => c.card === 94) && (
<img src="/card/badge_5.png" alt="Badge 5" className="w-6 h-6" />
)}
{cards.filter(c => c.card >= 96 && c.card <= 121).length === 26 && (
<img src="/card/badge_6.png" alt="Badge 6" className="w-6 h-6" />
)}
</div>
</div>
);
}