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
67 changes: 67 additions & 0 deletions app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Link } from 'react-router';
import { SupportedLanguage, saveLanguage } from '../locales';

interface HeaderProps {
currentLang: SupportedLanguage;
}

export function Header({ currentLang }: HeaderProps) {
const handleLanguageSwitch = (lang: SupportedLanguage) => {
saveLanguage(lang);
// Set cookie for server-side detection
document.cookie = `lang=${lang}; path=/; max-age=31536000`; // 1 year
};

return (
<header className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-900/80 backdrop-blur">
<Link to={`/${currentLang}`} className="text-xl font-bold tracking-tight">
Bug Sniper
</Link>
<div className="flex items-center space-x-4">
{/* Language switcher */}
<div className="flex items-center space-x-2">
<Link
to="/ja"
onClick={() => handleLanguageSwitch('ja')}
className={`text-2xl transition-opacity hover:opacity-100 ${
currentLang === 'ja' ? 'opacity-100' : 'opacity-50'
}`}
aria-label="日本語"
title="日本語"
>
🇯🇵
</Link>
<Link
to="/en"
onClick={() => handleLanguageSwitch('en')}
className={`text-2xl transition-opacity hover:opacity-100 ${
currentLang === 'en' ? 'opacity-100' : 'opacity-50'
}`}
aria-label="English"
title="English"
>
🇬🇧
</Link>
</div>

{/* GitHub link */}
<a
href="https://github.com/goofmint/BugSniper"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</header>
);
}
9 changes: 7 additions & 2 deletions app/locales/en.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"title": "Bug Sniper",
"catchphrase": "Find bugs for 60 seconds!",
"button.start": "Start",
"button.skip": "Skip",
"label.score": "Score",
"label.time": "Time",
"label.combo": "Combo",
"label.uiLanguage": "UI Language",
"label.codeLanguage": "Code Language",
"nav.ranking": "Ranking",
"nav.home": "Home",
Expand All @@ -14,5 +14,10 @@
"language.php": "PHP",
"language.ruby": "Ruby",
"language.java": "Java",
"language.dart": "Dart"
"language.dart": "Dart",
"rules.title": "Rules",
"rules.time": "60 seconds time limit",
"rules.tap": "Tap lines with bugs to report them",
"rules.combo": "Consecutive correct answers for combo bonus",
"rules.skip": "Skip difficult problems"
}
9 changes: 7 additions & 2 deletions app/locales/ja.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"title": "Bug Sniper",
"catchphrase": "60秒間バグを見つけ続けろ!",
"button.start": "スタート",
"button.skip": "スキップ",
"label.score": "スコア",
"label.time": "残り時間",
"label.combo": "コンボ",
"label.uiLanguage": "UI言語",
"label.codeLanguage": "コード言語",
"nav.ranking": "ランキング",
"nav.home": "ホーム",
Expand All @@ -14,5 +14,10 @@
"language.php": "PHP",
"language.ruby": "Ruby",
"language.java": "Java",
"language.dart": "Dart"
"language.dart": "Dart",
"rules.title": "ルール",
"rules.time": "制限時間は60秒",
"rules.tap": "バグがある行をタップして指摘",
"rules.combo": "連続正解でコンボボーナス",
"rules.skip": "難しい問題はスキップ可能"
}
7 changes: 5 additions & 2 deletions app/routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { type RouteConfig, index } from '@react-router/dev/routes';
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [index('routes/home.tsx')] satisfies RouteConfig;
export default [
index('routes/_index.tsx'),
route(':lang', 'routes/$lang.tsx'),
] satisfies RouteConfig;
44 changes: 44 additions & 0 deletions app/routes/$lang.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { redirect } from 'react-router';
import type { Route } from './+types/$lang';
import { SupportedLanguage } from '../locales';
import { Welcome } from '../welcome/welcome';
import { Header } from '../components/Header';

/**
* Language-specific home route
*/
export function meta({ params }: Route.MetaArgs) {
const lang = params.lang as SupportedLanguage;
return [
{ title: 'Bug Sniper' },
{
name: 'description',
content:
lang === 'ja'
? 'コードレビューゲーム - バグを見つけてスコアを競おう!'
: 'Code review game - Find bugs and compete for high scores!',
},
];
}
Comment on lines +10 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsafe type assertion in meta function.

Line 11 performs an unsafe type assertion of params.lang to SupportedLanguage without validation. Since the meta function runs before or alongside the loader, the validation in the loader (lines 28-29) does not protect the meta function. If a user accesses an invalid language route (e.g., /fr), the meta function will cast 'fr' to SupportedLanguage, potentially causing type confusion.

Apply this diff to add validation to the meta function:

 export function meta({ params }: Route.MetaArgs) {
-  const lang = params.lang as SupportedLanguage;
+  const lang = params.lang;
+  
+  // Use default language for invalid params
+  const validLang: SupportedLanguage = (lang === 'ja' || lang === 'en') ? lang : 'en';
+  
   return [
     { title: 'Bug Sniper' },
     {
       name: 'description',
       content:
-        lang === 'ja'
+        validLang === 'ja'
           ? 'コードレビューゲーム - バグを見つけてスコアを競おう!'
           : 'Code review game - Find bugs and compete for high scores!',
     },
   ];
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function meta({ params }: Route.MetaArgs) {
const lang = params.lang as SupportedLanguage;
return [
{ title: 'Bug Sniper' },
{
name: 'description',
content:
lang === 'ja'
? 'コードレビューゲーム - バグを見つけてスコアを競おう!'
: 'Code review game - Find bugs and compete for high scores!',
},
];
}
export function meta({ params }: Route.MetaArgs) {
const lang = params.lang;
// Use default language for invalid params
const validLang: SupportedLanguage = (lang === 'ja' || lang === 'en') ? lang : 'en';
return [
{ title: 'Bug Sniper' },
{
name: 'description',
content:
validLang === 'ja'
? 'コードレビューゲーム - バグを見つけてスコアを競おう!'
: 'Code review game - Find bugs and compete for high scores!',
},
];
}
🤖 Prompt for AI Agents
In app/routes/$lang.tsx around lines 10 to 22, avoid the unsafe cast of
params.lang to SupportedLanguage; instead treat params.lang as an
unknown/string, validate it (e.g., check if it equals 'ja' otherwise default to
'en' or check it against your canonical list of supported languages), then use
the validated value to pick the title/description. Replace the direct type
assertion with a small type guard or simple conditional that ensures only known
languages are used in meta and falls back to a safe default so invalid routes
(like /fr) can't cause type confusion.


export function loader({ params }: Route.LoaderArgs) {
const lang = params.lang;

// Validate language parameter
if (lang !== 'ja' && lang !== 'en') {
throw redirect('/');
}

return {
lang: lang as SupportedLanguage,
};
}

export default function LanguageHome({ loaderData }: Route.ComponentProps) {
return (
<>
<Header currentLang={loaderData.lang} />
<Welcome lang={loaderData.lang} />
</>
);
}
37 changes: 37 additions & 0 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { redirect } from 'react-router';
import type { Route } from './+types/_index';

/**
* Root index route - detects browser language and redirects to appropriate language route
*/
export function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);

// Check if there's a lang cookie
const cookieHeader = request.headers.get('Cookie');
let langFromCookie: string | null = null;

if (cookieHeader) {
const cookies = cookieHeader.split(';').map(c => c.trim());
const langCookie = cookies.find(c => c.startsWith('lang='));
if (langCookie) {
langFromCookie = langCookie.split('=')[1];
}
}

// Determine language: cookie > Accept-Language header
let lang = 'en';

if (langFromCookie === 'ja' || langFromCookie === 'en') {
lang = langFromCookie;
} else {
// Check Accept-Language header
const acceptLanguage = request.headers.get('Accept-Language');
if (acceptLanguage && acceptLanguage.toLowerCase().includes('ja')) {
lang = 'ja';
}
}

// Redirect to language-specific route
return redirect(`/${lang}`);
}
80 changes: 48 additions & 32 deletions app/welcome/welcome.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,55 @@
import logoDark from './logo-dark.svg';
import logoLight from './logo-light.svg';
import { SupportedLanguage, t } from '../locales';

export function Welcome({ message }: { message: string }) {
export function Welcome({ lang }: { lang: SupportedLanguage }) {
return (
<main className="flex items-center justify-center pt-16 pb-4">
<div className="flex-1 flex flex-col items-center gap-16 min-h-0">
<header className="flex flex-col items-center gap-9">
<div className="w-[500px] max-w-[100vw] p-4">
<img src={logoLight} alt="React Router" className="block w-full dark:hidden" />
<img src={logoDark} alt="React Router" className="hidden w-full dark:block" />
</div>
</header>
<div className="max-w-[300px] w-full space-y-6 px-4">
<nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
<p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
What&apos;s next?
</p>
<ul>
{resources.map(({ href, text, icon }) => (
<li key={href}>
<a
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
href={href}
target="_blank"
rel="noreferrer"
>
{icon}
{text}
</a>
</li>
))}
<li className="self-stretch p-3 leading-normal">{message}</li>
<main className="flex items-center justify-center min-h-[calc(100vh-56px)] px-4">
<div className="flex flex-col items-center justify-center space-y-6 w-full max-w-md">
<div className="text-center space-y-2">
<h1 className="text-4xl font-bold tracking-tight">{t('title', lang)}</h1>
<p className="text-xl text-sky-600 dark:text-sky-400 font-semibold">
{t('catchphrase', lang)}
</p>
</div>

<div className="w-full max-w-xs space-y-4">
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">{t('rules.title', lang)}</h2>
<ul className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
<li className="flex items-start">
<span className="mr-2">•</span>
<span>{t('rules.time', lang)}</span>
</li>
<li className="flex items-start">
<span className="mr-2">•</span>
<span>{t('rules.tap', lang)}</span>
</li>
<li className="flex items-start">
<span className="mr-2">•</span>
<span>{t('rules.combo', lang)}</span>
</li>
<li className="flex items-start">
<span className="mr-2">•</span>
<span>{t('rules.skip', lang)}</span>
</li>
</ul>
</nav>
</div>

<div>
<label className="block text-sm font-medium mb-1">{t('label.codeLanguage', lang)}</label>
<select className="w-full px-3 py-2 rounded-md bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700">
<option value="all">{t('language.all', lang)}</option>
<option value="javascript">{t('language.javascript', lang)}</option>
<option value="php">{t('language.php', lang)}</option>
<option value="ruby">{t('language.ruby', lang)}</option>
<option value="java">{t('language.java', lang)}</option>
<option value="dart">{t('language.dart', lang)}</option>
</select>
</div>
</div>

<button className="w-full max-w-xs py-3 text-lg font-semibold rounded-md bg-sky-500 text-white hover:bg-sky-600 active:bg-sky-700 transition">
{t('button.start', lang)}
</button>
</div>
</main>
);
Expand Down