// ============================================ // ソルマーレ長崎FC 情報共有アプリ v2 // React SPA + PWA // ============================================ const { useState, useEffect, useMemo, useCallback } = React; // ===== SVG Icons ===== const I = { Home:()=>, Calendar:()=>, Users:()=>, User:()=>, Book:()=>, Settings:()=>, Clock:()=>, MapPin:()=>, Flag:()=>, Pin:()=>, Play:()=>, Plus:()=>, Car:()=>, Star:()=>, ChevL:()=>, ChevR:()=>, AlertTri:()=>, File:()=>, Bell:()=>, RefreshCw:()=>, }; // ===== Data ===== const DOW = ['日','月','火','水','木','金','土']; const CAT_LABELS = {training:'トレーニング',trm:'TRM',cup:'カップ戦',official:'公式戦',other:'その他'}; const SKILL_LABELS = {shoot:'シュート',pass:'パス',dribble:'ドリブル',tactics:'戦術理解',physical:'フィジカル',gk:'GK',other:'その他'}; const LOC_LABELS = {indoor:'室内',outdoor:'屋外'}; const DEMO_SCHEDULES = [ {id:1,category:'training',title:'通常トレーニング',event_date:'2026-04-04',start_time:'17:00',end_time:'19:00',location:'南部グラウンド',description:'基礎練習とミニゲーム',attendance:{attend:14,absent:2,undecided:4}}, {id:2,category:'trm',title:'TRM vs 長崎南FC',event_date:'2026-04-05',start_time:'09:00',end_time:'12:00',location:'諫早市中央運動公園',gather_time:'08:00',gather_place:'南部グラウンド駐車場',opponent:'長崎南FC',age_group:'U-8',kickoff_time:'09:30',parking_limited:true,parking_limit_num:5,parking_note:'台数制限あり。乗り合わせにご協力ください。',description:'8人制。ユニフォーム上下、水筒、着替え持参。',attendance:{attend:16,absent:1,undecided:3}}, {id:3,category:'training',title:'通常トレーニング',event_date:'2026-04-08',start_time:'17:00',end_time:'19:00',location:'南部グラウンド',attendance:{attend:10,absent:3,undecided:7}}, {id:4,category:'official',title:'長崎市少年サッカー大会',event_date:'2026-04-12',start_time:'08:30',end_time:'16:00',location:'長崎市総合運動公園',gather_time:'07:30',gather_place:'南部グラウンド駐車場',age_group:'U-8',kickoff_time:'09:00',parking_limited:true,parking_limit_num:8,description:'トーナメント戦。弁当持参。',attendance:{attend:18,absent:0,undecided:2}}, {id:5,category:'cup',title:'春季ジュニアカップ',event_date:'2026-04-19',start_time:'08:00',end_time:'15:00',location:'大村市陸上競技場',gather_time:'06:30',gather_place:'南部グラウンド駐車場',opponent:'県内8チーム',age_group:'U-8',kickoff_time:'08:30',description:'グループリーグ+決勝T',attendance:{attend:15,absent:1,undecided:4}}, {id:6,category:'training',title:'通常トレーニング',event_date:'2026-04-11',start_time:'17:00',end_time:'19:00',location:'南部グラウンド',attendance:{attend:8,absent:2,undecided:10}}, ]; const DEMO_NEWS = [ {id:1,title:'4月の月会費について',category:'info',content:'4月分の月会費(3,000円)の引き落としは4月10日です。\n口座残高のご確認をお願いいたします。',is_pinned:true,created_by:'山田コーチ',created_at:'2026-04-01'}, {id:2,title:'【結果】3/29 春季交流戦',category:'result',content:'第1試合 ソルマーレ 3-1 長崎北FC\n第2試合 ソルマーレ 2-2 時津少年SC\n第3試合 ソルマーレ 4-0 大村キッカーズ\n\n3戦2勝1分で優勝!',is_pinned:false,created_by:'永野コーチ',created_at:'2026-03-30'}, {id:3,title:'4/12 長崎市大会のご案内',category:'event',content:'4月12日(日)長崎市総合運動公園にて開催。\n集合:7:30 南部グラウンド駐車場\n持ち物:ユニフォーム上下、水筒、弁当、着替え\n\n車出しの協力をお願いできる方は4/8までにご連絡ください。',is_pinned:true,created_by:'永野コーチ',created_at:'2026-03-28'}, {id:4,title:'【緊急】4/5 TRM 集合時間変更',category:'urgent',content:'4月5日のTRMの集合時間が変更になりました。\n\n変更前:8:30\n変更後:8:00\n\nお間違えのないようお願いします。',is_pinned:false,created_by:'永野コーチ',created_at:'2026-04-02'}, ]; const DEMO_PLAYERS = [ {id:1,last_name:'田中',first_name:'翔太',grade:'小1',birth_date:'2019-05-12',jersey_number:10}, {id:2,last_name:'山本',first_name:'蓮',grade:'小1',birth_date:'2019-07-03',jersey_number:7}, {id:3,last_name:'佐藤',first_name:'大翔',grade:'小1',birth_date:'2019-04-22',jersey_number:4}, {id:4,last_name:'鈴木',first_name:'陽斗',grade:'小1',birth_date:'2019-09-15',jersey_number:1}, {id:5,last_name:'高橋',first_name:'悠真',grade:'小1',birth_date:'2019-11-08',jersey_number:9}, {id:6,last_name:'伊藤',first_name:'湊',grade:'小1',birth_date:'2019-06-20',jersey_number:8}, {id:7,last_name:'渡辺',first_name:'颯',grade:'小1',birth_date:'2019-03-14',jersey_number:3}, {id:8,last_name:'中村',first_name:'樹',grade:'小1',birth_date:'2019-08-30',jersey_number:6}, {id:9,last_name:'永野',first_name:'○○',grade:'小1',birth_date:'2019-12-01',jersey_number:11}, {id:10,last_name:'小林',first_name:'蒼',grade:'小1',birth_date:'2019-02-18',jersey_number:2}, {id:11,last_name:'加藤',first_name:'陸',grade:'年長',birth_date:'2020-01-25',jersey_number:5}, {id:12,last_name:'吉田',first_name:'凛太朗',grade:'年長',birth_date:'2020-06-11',jersey_number:14}, {id:13,last_name:'松本',first_name:'瑛太',grade:'小1',birth_date:'2019-10-07',jersey_number:13}, {id:14,last_name:'井上',first_name:'律',grade:'年長',birth_date:'2020-04-02',jersey_number:15}, {id:15,last_name:'木村',first_name:'朝陽',grade:'小1',birth_date:'2019-07-19',jersey_number:12}, {id:16,last_name:'林',first_name:'結翔',grade:'小1',birth_date:'2019-05-28',jersey_number:16}, {id:17,last_name:'清水',first_name:'蒼空',grade:'年長',birth_date:'2020-09-05',jersey_number:17}, {id:18,last_name:'山口',first_name:'晴',grade:'小1',birth_date:'2019-04-16',jersey_number:18}, {id:19,last_name:'阿部',first_name:'湊斗',grade:'年長',birth_date:'2020-03-21',jersey_number:19}, {id:20,last_name:'石田',first_name:'碧',grade:'小1',birth_date:'2019-08-08',jersey_number:20}, ]; const DEMO_STAFF = [ {id:1,last_name:'田中',first_name:'健一',position_title:'監督',qualifications:'JFA公認C級コーチ / 4級審判'}, {id:2,last_name:'永野',first_name:'',position_title:'アシスタントコーチ',qualifications:'JFA公認D級コーチ'}, {id:3,last_name:'山田',first_name:'太郎',position_title:'コーチ / 会計',qualifications:'JFA公認D級コーチ'}, ]; const DEMO_MEMORY = { player_id:9, shoe_brand:'アシックス DSライト', shoe_size:'20.0cm', favorite_snack:'inゼリー(マスカット味)', first_goal_date:'2026-03-29', first_goal_note:'春季交流戦の第3試合、大村キッカーズ戦で左足シュート!', favorite_player:'三笘薫', personal_best:'50m走 10.2秒', notes:'練習前にストレッチを入念にやると調子がいい' }; const DEMO_LIBRARY = [ {id:1,url:'https://youtube.com/watch?v=example1',title:'【U-8向け】インサイドキックの基本',location_type:'outdoor',skill_category:'pass',added_by:'永野コーチ'}, {id:2,url:'https://youtube.com/watch?v=example2',title:'室内でできるボールタッチ練習30選',location_type:'indoor',skill_category:'dribble',added_by:'田中監督'}, {id:3,url:'https://tiktok.com/@example/video3',title:'シュートの蹴り方 3つのコツ',location_type:'outdoor',skill_category:'shoot',added_by:'山田コーチ'}, {id:4,url:'https://youtube.com/watch?v=example4',title:'ポジショニングの考え方【ジュニア向け】',location_type:'outdoor',skill_category:'tactics',added_by:'永野コーチ'}, {id:5,url:'https://youtube.com/watch?v=example5',title:'GK基礎:キャッチングとポジション',location_type:'outdoor',skill_category:'gk',added_by:'田中監督'}, {id:6,url:'https://youtube.com/watch?v=example6',title:'雨の日にできる体幹トレーニング',location_type:'indoor',skill_category:'physical',added_by:'永野コーチ'}, ]; // ===== Helpers ===== function fmtDate(s){const d=new Date(s+'T00:00:00');return{m:d.getMonth()+1,d:d.getDate(),dow:DOW[d.getDay()],dayOfWeek:d.getDay()};} function fmtDateFull(s){const{m,d,dow}=fmtDate(s);return `${m}月${d}日(${dow})`;} function timeAgo(s){const now=new Date();const d=new Date(s+'T00:00:00');const diff=Math.floor((now-d)/(1000*60*60*24));if(diff===0)return'今日';if(diff===1)return'昨日';if(diff<7)return`${diff}日前`;return fmtDateFull(s);} function greeting(){const h=new Date().getHours();return h<12?'おはようございます':h<18?'こんにちは':'こんばんは';} // ===== Calendar Helper ===== function getCalendarDays(year, month) { const first = new Date(year, month, 1); const last = new Date(year, month + 1, 0); const days = []; const startDow = first.getDay(); // Previous month for (let i = startDow - 1; i >= 0; i--) { const d = new Date(year, month, -i); days.push({ date: d, otherMonth: true }); } // Current month for (let i = 1; i <= last.getDate(); i++) { days.push({ date: new Date(year, month, i), otherMonth: false }); } // Next month fill const rem = 7 - (days.length % 7); if (rem < 7) { for (let i = 1; i <= rem; i++) { days.push({ date: new Date(year, month + 1, i), otherMonth: true }); } } return days; } function dateToStr(d) { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } // ===== Components ===== function Header({ onSettings }) { return (
ソルマーレ長崎FC
); } function BottomNav({ active, onChange }) { const items = [ { key:'home', label:'ホーム', icon:I.Home }, { key:'schedule', label:'予定', icon:I.Calendar }, { key:'team', label:'チーム', icon:I.Users }, { key:'player', label:'選手', icon:I.User }, { key:'library', label:'ライブラリ', icon:I.Book }, ]; return ( ); } // --- Mini Calendar --- function MiniCalendar({ events, onSelectDate, selectedDate }) { const today = new Date(); const [year, setYear] = useState(today.getFullYear()); const [month, setMonth] = useState(today.getMonth()); const days = useMemo(() => getCalendarDays(year, month), [year, month]); const eventDates = useMemo(() => new Set(events.map(e => e.event_date)), [events]); const prev = () => { if (month === 0) { setYear(y=>y-1); setMonth(11); } else setMonth(m=>m-1); }; const next = () => { if (month === 11) { setYear(y=>y+1); setMonth(0); } else setMonth(m=>m+1); }; const todayStr = dateToStr(today); return (
{year}年{month+1}月
{DOW.map((d,i)=>
{d}
)} {days.map((d,i)=>{ const ds = dateToStr(d.date); const dow = d.date.getDay(); let cls = 'cal-day'; if (d.otherMonth) cls += ' other-month'; if (dow === 0 && !d.otherMonth) cls += ' sunday'; if (dow === 6 && !d.otherMonth) cls += ' saturday'; if (ds === todayStr) cls += ' today'; if (eventDates.has(ds)) cls += ' has-event'; if (ds === selectedDate) cls += ' selected'; return
!d.otherMonth && onSelectDate(ds)}>{d.date.getDate()}
; })}
); } // --- Schedule Detail --- function ScheduleDetail({ s, onClose }) { const [att, setAtt] = useState(null); if (!s) return null; const { m, d, dow } = fmtDate(s.event_date); return (
e.stopPropagation()}>
{CAT_LABELS[s.category]}
{m}/{d}
{dow}曜日

{s.title}

{s.start_time &&
時間
{s.start_time}{s.end_time?` 〜 ${s.end_time}`:''}
} {s.location &&
場所
{s.location}
} {s.gather_time &&
集合
{s.gather_time} / {s.gather_place||s.location}
} {s.opponent &&
対戦相手
{s.opponent}
} {s.age_group &&
カテゴリ
{s.age_group}
} {s.kickoff_time &&
キックオフ
{s.kickoff_time}
} {s.parking_limited && (
駐車場制限あり{s.parking_limit_num?`(${s.parking_limit_num}台まで)`:''}{s.parking_note?` — ${s.parking_note}`:''}
)} {s.description && (
備考
{s.description}
)} {s.attendance && (
◯ {s.attendance.attend} ✕ {s.attendance.absent} △ {s.attendance.undecided}
)}
); } // --- Announcement Detail --- function AnnouncementDetail({ a, onClose }) { if (!a) return null; const catCls = {info:'ann-info',urgent:'ann-urgent',event:'ann-event',result:'ann-result'}; const catLabel = {info:'お知らせ',urgent:'緊急',event:'イベント',result:'結果'}; return (
e.stopPropagation()}>
{catLabel[a.category]}

{a.title}

{a.created_by}{timeAgo(a.created_at)}
{a.content}
); } // --- Schedule Item Row --- function ScheduleRow({ s, onClick }) { const { m, d, dow } = fmtDate(s.event_date); return (
{m}月
{d}
{dow}
{CAT_LABELS[s.category]}
{s.title}
{s.start_time||'未定'} {s.location && <>|{s.location}}
{s.attendance && (
◯{s.attendance.attend} ✕{s.attendance.absent} △{s.attendance.undecided}
)}
); } // ==================== PAGES ==================== // --- HOME --- function HomePage({ onNav }) { const [selSch, setSelSch] = useState(null); const [selAnn, setSelAnn] = useState(null); const [calDate, setCalDate] = useState(null); const sorted = [...DEMO_SCHEDULES].sort((a,b)=>a.event_date.localeCompare(b.event_date)); const nextEvent = sorted[0]; const catLabel = {info:'お知らせ',urgent:'緊急',event:'イベント',result:'結果'}; const catCls = {info:'ann-info',urgent:'ann-urgent',event:'ann-event',result:'ann-result'}; const calEvents = calDate ? sorted.filter(s => s.event_date === calDate) : []; return (
{greeting()}、永野さん
{nextEvent && <>
NEXT
{fmtDateFull(nextEvent.event_date)} {nextEvent.start_time && nextEvent.start_time+'〜'}
{nextEvent.title}
}
{DEMO_PLAYERS.length}
選手
{sorted.filter(s=>s.category!=='training').length}
今月の試合
{DEMO_NEWS.filter(n=>n.is_pinned||n.category==='urgent').length}
重要
{/* Calendar */} setCalDate(calDate === d ? null : d)} /> {calDate && calEvents.length > 0 && (
{fmtDateFull(calDate)} の予定
{calEvents.map(s => setSelSch(s)} />)}
)} {/* Upcoming */}
直近の予定
{sorted.slice(0,3).map(s => setSelSch(s)} />)}
{/* News */}
新着情報
{[...DEMO_NEWS].sort((a,b)=>{if(a.is_pinned!==b.is_pinned)return b.is_pinned?1:-1;return b.created_at.localeCompare(a.created_at);}).slice(0,3).map(a=>(
setSelAnn(a)}>
{a.is_pinned && } {catLabel[a.category]}
{a.title}
{a.created_by}{timeAgo(a.created_at)}
))}
{selSch && setSelSch(null)} />} {selAnn && setSelAnn(null)} />}
); } // --- SCHEDULE --- function SchedulePage() { const [view, setView] = useState('today'); const [selSch, setSelSch] = useState(null); const today = new Date(); const todayStr = dateToStr(today); const tmrw = new Date(today); tmrw.setDate(tmrw.getDate()+1); const tmrwStr = dateToStr(tmrw); const sorted = [...DEMO_SCHEDULES].sort((a,b)=>a.event_date.localeCompare(b.event_date)); const endOfWeek = new Date(today); endOfWeek.setDate(endOfWeek.getDate() + (7 - endOfWeek.getDay())); const weekStr = dateToStr(endOfWeek); const endOfMonth = new Date(today.getFullYear(), today.getMonth()+1, 0); const monthStr = dateToStr(endOfMonth); let filtered; if (view === 'today') { filtered = sorted.filter(s => s.event_date === todayStr || s.event_date === tmrwStr); } else if (view === 'week') { filtered = sorted.filter(s => s.event_date >= todayStr && s.event_date <= weekStr); } else { filtered = sorted.filter(s => s.event_date >= todayStr && s.event_date <= monthStr); } return (
{[{k:'today',l:'今日・明日'},{k:'week',l:'週間'},{k:'month',l:'月間'}].map(t=>( ))}
{filtered.length === 0 ? (

この期間の予定はありません

) : filtered.map(s => setSelSch(s)} />)}
{selSch && setSelSch(null)} />}
); } // --- TEAM --- function TeamPage() { const [tab, setTab] = useState('players'); return (
{tab === 'players' && (
{[...DEMO_PLAYERS].sort((a,b)=>a.jersey_number-b.jersey_number).map(p=>(
{p.last_name.charAt(0)}
{p.last_name} {p.first_name}
{p.grade} / {p.birth_date}
#{p.jersey_number}
))}
)} {tab === 'staff' && (
{DEMO_STAFF.map(s=>(
{s.last_name.charAt(0)}
{s.last_name} {s.first_name}
{s.position_title}
{s.qualifications}
))}
)}
); } // --- PLAYER (my child) --- function PlayerPage() { const child = DEMO_PLAYERS.find(p => p.id === 9); // 永野 ○○ const mem = DEMO_MEMORY; if (!child) return

お子さまの情報が登録されていません

; return (
{child.last_name} {child.first_name}
{child.grade} / {child.birth_date}
#{child.jersey_number}

スパイク

{mem.shoe_brand}

{mem.shoe_size}

お気に入り補食

{mem.favorite_snack}

初ゴール

{fmtDateFull(mem.first_goal_date)}

憧れの選手

{mem.favorite_player}

{mem.first_goal_note && (

初ゴールの思い出

{mem.first_goal_note}

)} {mem.personal_best && (

自己ベスト

{mem.personal_best}

)} {mem.notes && (

メモ

{mem.notes}

)}
); } // --- LIBRARY --- function LibraryPage() { const [filter, setFilter] = useState('all'); const filtered = filter === 'all' ? DEMO_LIBRARY : DEMO_LIBRARY.filter(l => l.skill_category === filter); return (
{[{k:'all',l:'すべて'},{k:'shoot',l:'シュート'},{k:'pass',l:'パス'},{k:'dribble',l:'ドリブル'},{k:'tactics',l:'戦術'},{k:'physical',l:'フィジカル'},{k:'gk',l:'GK'}].map(t=>( ))}
{filtered.length === 0 ? (

該当する動画がありません

) : filtered.map(l => (
{l.title}
{LOC_LABELS[l.location_type]} {SKILL_LABELS[l.skill_category]}
{l.added_by}
))}
); } // --- SETTINGS --- const APP_VERSION = '2.0.0'; function SettingsPanel({ onClose }) { const [updating, setUpdating] = useState(false); const handleForceUpdate = async () => { setUpdating(true); try { // 1. Service Worker の全キャッシュを削除 if ('caches' in window) { const keys = await caches.keys(); await Promise.all(keys.map(key => caches.delete(key))); } // 2. Service Worker を再登録(待機中のSWがあればスキップさせる) if ('serviceWorker' in navigator) { const registrations = await navigator.serviceWorker.getRegistrations(); await Promise.all(registrations.map(r => r.unregister())); } // 3. 強制リロード(キャッシュ無視) window.location.reload(true); } catch (e) { console.error('Update failed:', e); // フォールバック: 普通にリロード window.location.reload(true); } }; return (
e.stopPropagation()}>

設定

アカウント情報永野 >
お子さま管理1人 >
通知設定>
権限スタッフ
ログアウト
アプリバージョン
v{APP_VERSION}
キャッシュをクリアしてページを再読み込みします
); } // ==================== APP ==================== function App() { const [page, setPage] = useState('home'); const [showSettings, setShowSettings] = useState(false); const renderPage = () => { switch(page) { case 'home': return ; case 'schedule': return ; case 'team': return ; case 'player': return ; case 'library': return ; default: return ; } }; return ( <>
setShowSettings(true)} />
{renderPage()}
{showSettings && setShowSettings(false)} />} ); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();