Compare commits
21 Commits
8aec22cc86
...
main
Author | SHA1 | Date | |
---|---|---|---|
2339aa5071
|
|||
2767842aae
|
|||
054846f8e5
|
|||
60076d0e83
|
|||
8fa9747847
|
|||
![]() |
2a698fa9af | ||
8d6137ce67
|
|||
![]() |
2b954ec582 | ||
94062298ab
|
|||
14873a9589
|
|||
e829de6878
|
|||
3302ceb852
|
|||
815dfb26e0
|
|||
f8c37a1f73
|
|||
3e3244c852
|
|||
![]() |
416a71d3f0 | ||
c050a639af
|
|||
2cd1a62e5d
|
|||
510899a0dd
|
|||
56fa9c3ab6
|
|||
8b27c8e829
|
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(npm install)",
|
||||
"Bash(nvm use:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm run preview:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(npx serve:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(open:*)",
|
||||
"WebFetch(domain:api.syui.ai)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
9
.github/workflows/gh-pages.yml
vendored
9
.github/workflows/gh-pages.yml
vendored
@@ -4,18 +4,18 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- react-migration
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
- run: |
|
||||
npm install
|
||||
|
||||
@@ -26,9 +26,10 @@ jobs:
|
||||
npm run build
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./dist
|
||||
force_orphan: true
|
||||
user_name: 'ai[bot]'
|
||||
user_email: '138105980+yui-syui-ai[bot]@users.noreply.github.com'
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ yarn-error.log
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
**DS_Store
|
||||
.claude
|
||||
repos
|
||||
|
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ai.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>card.syui.ai</title>
|
||||
<link rel="stylesheet" href="/pkg/icomoon/style.css" />
|
||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css" />
|
||||
@@ -12,4 +13,4 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc && vite build && cp dist/index.html dist/404.html",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
|
@@ -1,12 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>card.syui.ai</title><link href="app.css" rel="preload" as="style"><link href="app.js" rel="preload" as="script"><link href="chunk-vendors.js" rel="preload" as="script"><link href="app.css" rel="stylesheet">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta property="og:url" content="https://card.syui.aiL">
|
||||
<meta property="og:title" content="card.syui.ai">
|
||||
<meta property="og:description" content="@yui.bsky.social /card">
|
||||
<meta property="og:image" content="https://card.syui.ai/card/card_0.png">
|
||||
</head>
|
||||
<body><div id="app"></div><script src="chunk-vendors.js"></script><script src="app.js"></script></body>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ai.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>card.syui.ai</title>
|
||||
<link rel="stylesheet" href="/pkg/icomoon/style.css" />
|
||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css" />
|
||||
<script type="module" crossorigin src="/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/index.css">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta property="og:url" content="https://card.syui.ai">
|
||||
<meta property="og:title" content="card.syui.ai">
|
||||
<meta property="og:description" content="@yui.bsky.social /card">
|
||||
<meta property="og:image" content="https://card.syui.ai/card/card_0.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
43
readme.md
43
readme.md
@@ -1,10 +1,45 @@
|
||||
# card
|
||||
|
||||
- host : [card.syui.ai](https://card.syuiai)
|
||||
A React-based card collection viewer with holographic effects.
|
||||
|
||||
## Overview
|
||||
|
||||
- Production URL: [card.syui.ai](https://card.syui.ai)
|
||||
- API: https://api.syui.ai
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- React Query (TanStack Query)
|
||||
- React Router v6
|
||||
|
||||
## Features
|
||||
|
||||
- User card collections display
|
||||
- Holographic effects for special status cards
|
||||
- Favorite card highlighting
|
||||
- Responsive grid layout
|
||||
- Fast initial loading with JSON caching
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
$ nvm use 16
|
||||
$ yarn install
|
||||
$ yarn dev
|
||||
# Node.js 20+ recommended
|
||||
$ npm install
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
$ npm run build
|
||||
$ npm run preview
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
The app runs on localhost:8080 by default and uses proxy for API calls during development.
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import HomePage from './components/pages/HomePage';
|
||||
import DocsPage from './components/pages/DocsPage';
|
||||
import OwnerPage from './components/pages/OwnerPage';
|
||||
import UserPage from './components/pages/UserPage';
|
||||
import CardDisplayPage from './components/pages/CardDisplayPage';
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
@@ -28,6 +29,7 @@ function App() {
|
||||
<Route path="/fa" element={<DocsPage page="fanart" />} />
|
||||
<Route path="/ph" element={<DocsPage page="photo" />} />
|
||||
<Route path="/pr" element={<DocsPage page="favorite" />} />
|
||||
<Route path="/cards/:id" element={<CardDisplayPage />} />
|
||||
<Route path="/:username" element={<UserPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
1910
src/App.vue
1910
src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ export default function Navigation({ username: _username }: NavigationProps) {
|
||||
<a href="/" className="text-accent text-2xl px-4">
|
||||
<span className="icon-ai"></span>
|
||||
</a>
|
||||
<code className="bg-dark p-0">
|
||||
<code className="bg-dark p-0 hidden md:block">
|
||||
<a
|
||||
href="https://bsky.app/profile/yui.syui.ai"
|
||||
target="_blank"
|
||||
@@ -45,32 +45,6 @@ export default function Navigation({ username: _username }: NavigationProps) {
|
||||
</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>
|
||||
);
|
||||
|
62
src/components/pages/CardDisplayPage.tsx
Normal file
62
src/components/pages/CardDisplayPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchCardById } from '../../utils/api';
|
||||
import '../../styles/card-effects.css';
|
||||
import '../../styles/card-fullscreen.css';
|
||||
|
||||
export default function CardDisplayPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const cardId = parseInt(id || '0');
|
||||
|
||||
const { data: card, isLoading } = useQuery({
|
||||
queryKey: ['card', cardId],
|
||||
queryFn: () => fetchCardById(cardId),
|
||||
enabled: cardId > 0,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<i className="fa-solid fa-spinner fa-spin text-6xl text-yellow-500"></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSpecialStatus = ['yui', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seven'].includes(card.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom, #1a1a1a, #2d2d2d, #1a1a1a)',
|
||||
backgroundColor: '#1a1a1a'
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="relative" style={{ maxHeight: '100vh', height: '100vh', width: 'auto' }}>
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<div className="card-wrapper-fullscreen" style={{ height: '100vh', width: '100%', maxWidth: 'none', aspectRatio: '5/7' }}>
|
||||
<div className="card-reflection">
|
||||
<img
|
||||
src={`/card/card_${card.card}.webp`}
|
||||
alt={`Card ${card.card}`}
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
{isSpecialStatus && (
|
||||
<>
|
||||
<div className={`card-status pattern-${card.status}`}></div>
|
||||
<div className={`card-status color-${card.status}`}></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,14 +1,35 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import Navigation from '../common/Navigation';
|
||||
import { fetchUsers } from '../../utils/api';
|
||||
import { fetchUsersWithCache } from '../../utils/api';
|
||||
import { api } from '../../utils/api';
|
||||
|
||||
export default function HomePage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => fetchUsers()
|
||||
queryFn: () => fetchUsersWithCache(),
|
||||
staleTime: 0, // Always consider data stale
|
||||
});
|
||||
|
||||
// Refresh with API data after cache load
|
||||
useEffect(() => {
|
||||
if (users?.isFromCache) {
|
||||
// Fetch fresh data from API in background
|
||||
api.get('users?itemsPerPage=8000').then(response => {
|
||||
console.log('Background API fetch successful, got', response.data.length, 'users with planet data');
|
||||
// Update the query cache with fresh data
|
||||
queryClient.setQueryData(['users'], {
|
||||
data: response.data,
|
||||
isFromCache: false
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('Background API fetch failed:', error);
|
||||
});
|
||||
}
|
||||
}, [users?.isFromCache, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
|
@@ -4,17 +4,27 @@ import Navigation from '../common/Navigation';
|
||||
import CardGrid from '../card/CardGrid';
|
||||
import UserProfile from '../user/UserProfile';
|
||||
import SpecialCard from '../card/SpecialCard';
|
||||
import { fetchUsers, fetchUserCards } from '../../utils/api';
|
||||
import { fetchUsers, fetchUserCards, fetchUser } from '../../utils/api';
|
||||
|
||||
export default function UserPage() {
|
||||
const { username } = useParams<{ username: string }>();
|
||||
|
||||
// First get users list to find the user ID
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => fetchUsers(),
|
||||
});
|
||||
|
||||
const user = users?.data.find(u => u.username === username);
|
||||
const userId = users?.data.find(u => u.username === username)?.id;
|
||||
|
||||
// Then fetch full user data from API to get planet value
|
||||
const { data: userResponse } = useQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const user = userResponse?.data || users?.data.find(u => u.username === username);
|
||||
|
||||
const { data: cards, isLoading: cardsLoading } = useQuery({
|
||||
queryKey: ['userCards', user?.id],
|
||||
@@ -59,7 +69,7 @@ export default function UserPage() {
|
||||
{/* Favorite Card Section */}
|
||||
{user.fav && user.fav !== '0' && cards?.data && (
|
||||
(() => {
|
||||
const favCard = cards.data.find(card => card.id === parseInt(user.fav));
|
||||
const favCard = cards.data.find(card => card.id === parseInt(user.fav || '0'));
|
||||
if (favCard) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
|
@@ -6,6 +6,7 @@ interface UserProfileProps {
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -29,7 +30,7 @@ export default function UserProfile({ user, cards }: UserProfileProps) {
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>ID:</strong> {user.id}
|
||||
</div>
|
||||
@@ -45,12 +46,10 @@ export default function UserProfile({ user, cards }: UserProfileProps) {
|
||||
<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 className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-earth-americas"></i>
|
||||
{user.planet?.toLocaleString() || '0'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge Images */}
|
||||
|
@@ -1,9 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import VueMeta from 'vue-meta'
|
||||
Vue.use(VueMeta)
|
||||
Vue.config.productionTip = false
|
||||
|
||||
new Vue({
|
||||
render: h => h(App)
|
||||
}).$mount('#app')
|
7
src/styles/card-fullscreen.css
Normal file
7
src/styles/card-fullscreen.css
Normal file
@@ -0,0 +1,7 @@
|
||||
/* Fullscreen Card Display Styles */
|
||||
.card-wrapper-fullscreen {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
@@ -15,32 +15,44 @@ export const api = axios.create({
|
||||
// API関数
|
||||
export const fetchUsers = async (itemsPerPage = 8000): Promise<{ data: User[] }> => {
|
||||
try {
|
||||
// First try to load cached users.json for fast initial loading
|
||||
const cachedResponse = await axios.get('/json/users.json');
|
||||
|
||||
// Background fetch for fresh data
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await api.get(`users?itemsPerPage=${itemsPerPage}`);
|
||||
} catch (error) {
|
||||
console.error('Background fetch failed:', error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return { data: cachedResponse.data };
|
||||
// Directly fetch from API to get complete user data including planet
|
||||
const response = await api.get(`users?itemsPerPage=${itemsPerPage}`);
|
||||
return { data: response.data };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cached users:', error);
|
||||
// Fallback to API if cached JSON fails
|
||||
console.error('Failed to fetch users from API:', error);
|
||||
// Fallback to cached data (but it doesn't have planet field)
|
||||
try {
|
||||
const response = await api.get(`users?itemsPerPage=${itemsPerPage}`);
|
||||
return { data: response.data };
|
||||
} catch (apiError) {
|
||||
console.error('API fallback failed:', apiError);
|
||||
const cachedResponse = await axios.get('/json/users.json');
|
||||
return { data: cachedResponse.data };
|
||||
} catch (cacheError) {
|
||||
console.error('Cache fallback failed:', cacheError);
|
||||
return { data: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch users with cache-first strategy for home page
|
||||
export const fetchUsersWithCache = async (): Promise<{ data: User[], isFromCache?: boolean }> => {
|
||||
try {
|
||||
// First, try to get cached data
|
||||
console.log('Attempting to load cached users.json');
|
||||
const cachedResponse = await axios.get('/json/users.json');
|
||||
console.log('Successfully loaded cached data:', cachedResponse.data.length, 'users');
|
||||
return { data: cachedResponse.data, isFromCache: true };
|
||||
} catch (error) {
|
||||
console.error('Cache read failed, fetching from API:', error);
|
||||
// If cache fails, fetch from API
|
||||
try {
|
||||
const response = await api.get('users?itemsPerPage=8000');
|
||||
console.log('Successfully loaded from API:', response.data.length, 'users');
|
||||
return { data: response.data, isFromCache: false };
|
||||
} catch (apiError) {
|
||||
console.error('API fetch also failed:', apiError);
|
||||
return { data: [], isFromCache: false };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchUserCards = async (userId: number, itemsPerPage = 8000): Promise<{ data: Card[] }> => {
|
||||
try {
|
||||
const response = await api.get(`users/${userId}/card?itemsPerPage=${itemsPerPage}`);
|
||||
|
@@ -9,7 +9,8 @@ export const SKILL_ICONS = {
|
||||
yui: 'icon-ai',
|
||||
'3d': '■',
|
||||
model: 'fa-solid fa-cube',
|
||||
first: 'icon-moji_a'
|
||||
first: 'icon-moji_a',
|
||||
normal: ''
|
||||
} as const;
|
||||
|
||||
export const PLANET_THRESHOLDS = {
|
||||
|
@@ -1,23 +0,0 @@
|
||||
module.exports = {
|
||||
devServer: {
|
||||
proxy: {
|
||||
"^/api*": {
|
||||
target: "https://api.syui.ai",
|
||||
pathRewrite: { "^/api": "" },
|
||||
}
|
||||
}
|
||||
},
|
||||
publicPath: "/",
|
||||
configureWebpack: {
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
chunkFilename: '[name].js'
|
||||
}
|
||||
},
|
||||
css: {
|
||||
extract: {
|
||||
filename: '[name].css',
|
||||
chunkFilename: '[name].css'
|
||||
},
|
||||
},
|
||||
}
|
Reference in New Issue
Block a user