From 3e48768354a35ec19bedd6c9e399007a962d69a4 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Wed, 27 May 2026 19:54:18 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20view=20=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=83=89=E5=BB=83=E6=AD=A2=E3=83=BB=E3=83=95=E3=82=A3=E3=83=AB?= =?UTF-8?q?=E3=82=BF/=E6=99=82=E9=96=93=E5=89=B2=E3=83=AC=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=82=A6=E3=83=88=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - view モードを廃止し edit モードのみに統一 - Mode 型・mode/setMode を Context と URL から除去 - Header の Edit/View SegmentedControl を削除 - SearchPanel・LectureTable の isEdit ガードを除去 - 時間割セルの各講義に i ボタン (IconInfoCircle) を追加 - クリックで DetailModal を開く (stopPropagation でセルトグルと共存) - view モードでのみ開けていた詳細を常時参照可能に - デスクトップ: 検索フィルタ左1/3・時間割右2/3 の横並びレイアウト - モバイル: 時間割上・検索フィルタ下 (Grid order で実現) - 外周スペース 20px・コンテナ間スペース 10px に統一 - npm scripts に typecheck (tsc -b --noEmit) を追加 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + src/components/layout/Header.tsx | 15 +------- src/components/main/Calendar.tsx | 33 ++++++++-------- src/components/main/LectureTable.tsx | 6 +-- src/components/main/MainContent.tsx | 14 +++++-- src/components/main/PeriodCell.module.css | 46 ++++++++--------------- src/components/main/PeriodCell.tsx | 40 +++++++++++--------- src/components/main/SearchPanel.tsx | 8 +--- src/context/AppContext.tsx | 18 ++------- src/types.ts | 2 - 10 files changed, 72 insertions(+), 111 deletions(-) diff --git a/package.json b/package.json index 250a945..482b429 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "typecheck": "tsc -b --noEmit", "lint": "eslint .", "preview": "vite preview" }, diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index d51f6c2..676fb1e 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,11 +1,9 @@ import { useNavigate, useLocation } from 'react-router'; -import { ActionIcon, SegmentedControl, Anchor, Text } from '@mantine/core'; +import { ActionIcon, Anchor, Text } from '@mantine/core'; import { IconSearch, IconSettings } from '@tabler/icons-react'; -import { useAppContext } from '../../context/AppContext.tsx'; import classes from './Header.module.css'; export function Header() { - const { mode, setMode } = useAppContext(); const navigate = useNavigate(); const { pathname } = useLocation(); @@ -39,16 +37,6 @@ export function Header() { ut.code(); {showControls && ( - <> - setMode(v as 'view' | 'edit')} - data={[ - { value: 'edit', label: 'Edit' }, - { value: 'view', label: 'View' }, - ]} - size="sm" - /> - )} { const blank: string[] = []; @@ -26,13 +25,13 @@ export function Calendar() { }; return ( - + 時間割 現在{credits}単位 -
+
{/* Corner */}
{/* Day headers */} @@ -43,8 +42,8 @@ export function Calendar() { return ( togglePeriod(periods) : undefined} + className={`${classes.headerCell} ${isToday ? classes.today : ''}`} + onClick={() => togglePeriod(periods)} > {dayJp} @@ -55,7 +54,7 @@ export function Calendar() { togglePeriod(HEADER_ID_TO_PERIODS.get(`all-${time}`) ?? []) : undefined} + onClick={() => togglePeriod(HEADER_ID_TO_PERIODS.get(`all-${time}`) ?? [])} > {time} @@ -69,7 +68,7 @@ export function Calendar() { {/* 集中 */} togglePeriod(['集中']) : undefined} + onClick={() => togglePeriod(['集中'])} > 集 @@ -78,16 +77,14 @@ export function Calendar() {
- {isEdit && ( - - - - - )} + + + + ); } diff --git a/src/components/main/LectureTable.tsx b/src/components/main/LectureTable.tsx index 4114256..9386edc 100644 --- a/src/components/main/LectureTable.tsx +++ b/src/components/main/LectureTable.tsx @@ -52,15 +52,11 @@ function LectureRow({ lecture }: { lecture: Lecture }) { } export function LectureTable() { - const { mode } = useAppContext(); const { displayTab, setDisplayTab, filteredLectures } = useSearchContext(); - const isEdit = mode === 'edit'; - - if (!isEdit) return null; return ( - - + + + + + + + + ); diff --git a/src/components/main/PeriodCell.module.css b/src/components/main/PeriodCell.module.css index 628bec3..8232f00 100644 --- a/src/components/main/PeriodCell.module.css +++ b/src/components/main/PeriodCell.module.css @@ -8,69 +8,55 @@ min-height: 3rem; width: 100%; height: 100%; -} - -.editMode { border-radius: 1.5rem; border: 1px solid #ccc; background-color: #f8f8f8; } -.editMode.checked { +.cell.checked { background-color: var(--mantine-color-syllabusGreen-3); color: #fff; } -.editMode:hover { +.cell:hover { background-color: #ccc; } -.editMode.checked:hover { +.cell.checked:hover { background-color: var(--mantine-color-syllabusGreen-5); } -.viewMode { - pointer-events: none; -} - -.viewMode .lectureBox { - pointer-events: all; -} - .intensive { border-radius: 1.5rem; } .lectureBox { display: flex; - justify-content: center; align-items: center; - padding: 2px 6px; + padding: 2px 4px 2px 6px; border-radius: 1rem; font-size: 0.8rem; color: #333; - background: transparent; - border: none; - cursor: pointer; - text-align: center; width: 100%; +} + +.lectureName { + flex: 1; word-break: break-all; + text-align: center; } -.editMode .lectureBox { - pointer-events: none; +.infoBtn { + flex-shrink: 0; + opacity: 0.6; } -.viewMode .lectureBox { - background: var(--mantine-color-blue-0); - border: 1px solid var(--mantine-color-blue-4); - box-shadow: 0 2px 6px rgba(0,0,0,0.15); - transition: transform 0.2s; - border-radius: 8px; +.infoBtn:hover { + opacity: 1; } -.viewMode .lectureBox:hover { - transform: translateY(-2px); +.cell.checked .infoBtn { + color: #fff; } .required::before { diff --git a/src/components/main/PeriodCell.tsx b/src/components/main/PeriodCell.tsx index 77b09e7..8d910db 100644 --- a/src/components/main/PeriodCell.tsx +++ b/src/components/main/PeriodCell.tsx @@ -1,3 +1,5 @@ +import { ActionIcon } from '@mantine/core'; +import { IconInfoCircle } from '@tabler/icons-react'; import { useAppContext } from '../../context/AppContext.tsx'; import { importanceFor } from '../../lib/lectures.ts'; import classes from './PeriodCell.module.css'; @@ -8,38 +10,42 @@ interface PeriodCellProps { } export function PeriodCell({ period, isIntensive = false }: PeriodCellProps) { - const { mode, selectedPeriods, groupedByPeriod, togglePeriod, setCode, personal } = useAppContext(); + const { selectedPeriods, groupedByPeriod, togglePeriod, setCode, personal } = useAppContext(); const isChecked = selectedPeriods.has(period); const grouped = groupedByPeriod.get(period); const stream = personal?.stream ?? ''; - const isEdit = mode === 'edit'; - - const handleCellClick = () => { - if (isEdit) togglePeriod([period]); - }; return (
); })} diff --git a/src/components/main/SearchPanel.tsx b/src/components/main/SearchPanel.tsx index e61371a..35b05d2 100644 --- a/src/components/main/SearchPanel.tsx +++ b/src/components/main/SearchPanel.tsx @@ -5,7 +5,7 @@ import { useSearchContext } from '../../context/SearchContext.tsx'; import { ConditionFilter } from './ConditionFilter.tsx'; export function SearchPanel() { - const { setCondition, personal, mode } = useAppContext(); + const { setCondition, personal } = useAppContext(); const { freeword, setFreeword, searchAll, setSearchAll, @@ -13,8 +13,6 @@ export function SearchPanel() { inputHistory, setInputHistory, } = useSearchContext(); - const isEdit = mode === 'edit'; - const handleFreewordBlur = useCallback(() => { if (!freeword) return; setInputHistory([freeword, ...inputHistory.filter((h) => h !== freeword)].slice(0, 10)); @@ -24,10 +22,8 @@ export function SearchPanel() { setCondition(defaultCondition(personal?.stream ?? 'default')); }, [setCondition, personal]); - if (!isEdit) return null; - return ( - + 検索条件 ; selectedPeriods: Set; condition: Condition; - mode: Mode; code: string | null; credits: number; groupedByPeriod: Map>; @@ -44,7 +43,6 @@ interface AppContextValue { toggleRegister: (lecture: Lecture) => void; clearRegistered: () => void; setRegisteredCodes: (codes: Set) => void; - setMode: (m: Mode) => void; setCode: (c: string | null) => void; clearCode: () => void; goToMain: (skipCompulsory?: boolean, statusOverride?: PersonalStatus) => Promise; @@ -85,7 +83,6 @@ export function AppProvider({ return saved ?? defaultCondition('default'); }); - const mode = (searchParams.get('mode') as Mode) ?? 'edit'; const code = searchParams.get('code'); useEffect(() => { @@ -113,13 +110,6 @@ export function AppProvider({ setRegisteredCodesState(codes); }, []); - const setMode = useCallback( - (m: Mode) => { - setSearchParams((prev) => { prev.set('mode', m); return prev; }, { replace: true }); - }, - [setSearchParams], - ); - const setCode = useCallback( (c: string | null) => { setSearchParams( @@ -218,7 +208,6 @@ export function AppProvider({ registeredCodes, selectedPeriods, condition, - mode, code, credits, groupedByPeriod, @@ -229,7 +218,6 @@ export function AppProvider({ toggleRegister, clearRegistered, setRegisteredCodes, - setMode, setCode, clearCode, goToMain, @@ -237,9 +225,9 @@ export function AppProvider({ }), [ lectures, requiredDB, personal, registeredCodes, selectedPeriods, - condition, mode, code, credits, groupedByPeriod, + condition, code, credits, groupedByPeriod, setPersonal, setCondition, togglePeriod, setPeriods, - toggleRegister, clearRegistered, setRegisteredCodes, setMode, setCode, clearCode, + toggleRegister, clearRegistered, setRegisteredCodes, setCode, clearCode, goToMain, clearCache, ], ); diff --git a/src/types.ts b/src/types.ts index 67f9483..f9223ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,4 @@ export interface Condition { registration: Record; } -export type Mode = 'view' | 'edit'; - export type RequiredDB = [Record, Record]; From 3b3dda2906ab1f5a816d218b8d0a07da4bd66d68 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Wed, 27 May 2026 23:19:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=203=E3=82=AB=E3=83=A9=E3=83=A0?= =?UTF-8?q?=E6=A7=8B=E6=88=90=E3=81=AB=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/main/Calendar.tsx | 159 +++++--- .../main/ConditionFilter.module.css | 46 +-- src/components/main/ConditionFilter.tsx | 366 ++++++++++++------ src/components/main/LectureTable.tsx | 103 +++-- src/components/main/MainContent.tsx | 77 +++- src/components/main/PeriodCell.tsx | 4 +- src/components/main/SearchPanel.tsx | 110 +++--- src/context/AppContext.tsx | 2 +- src/lib/constants.ts | 3 +- src/lib/filters.ts | 23 +- src/types.ts | 4 +- 11 files changed, 563 insertions(+), 334 deletions(-) diff --git a/src/components/main/Calendar.tsx b/src/components/main/Calendar.tsx index ed451ad..1508ca8 100644 --- a/src/components/main/Calendar.tsx +++ b/src/components/main/Calendar.tsx @@ -1,5 +1,6 @@ import { Fragment } from 'react'; -import { Button, UnstyledButton, Paper, Text, Box, Group } from '@mantine/core'; +import { ActionIcon, Button, UnstyledButton, Paper, Text, Box, Group } from '@mantine/core'; +import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from '@tabler/icons-react'; import { useAppContext } from '../../context/AppContext.tsx'; import { PeriodCell } from './PeriodCell.tsx'; import { @@ -8,7 +9,13 @@ import { } from '../../lib/constants.ts'; import classes from './Calendar.module.css'; -export function Calendar() { +interface Props { + collapsed: boolean; + onToggle: () => void; + isDesktop: boolean; +} + +export function Calendar({ collapsed, onToggle, isDesktop }: Props) { const { credits, togglePeriod, setPeriods, groupedByPeriod } = useAppContext(); const selectBlank = () => { @@ -24,67 +31,107 @@ export function Calendar() { setPeriods(blank); }; + if (collapsed && isDesktop) { + return ( + + + + + + 時間割 + + + ); + } + return ( - - 時間割 - - 現在{credits}単位 - + + + 時間割 + + {!collapsed && ( + + 現在{credits}単位 + + )} + + {isDesktop + ? + : collapsed + ? + : + } + + + -
- {/* Corner */} -
- {/* Day headers */} - {DAY_JP_LIST.map((dayJp, di) => { - const dayEn = DAY_EN_LIST[di]; - const periods = HEADER_ID_TO_PERIODS.get(`${dayEn}-all`) ?? []; - const isToday = dayEn === TODAY_EN; - return ( - togglePeriod(periods)} - > - {dayJp} - - ); - })} - {/* Time rows */} - {TIME_LIST.map((time) => ( - + {!collapsed && ( + <> +
+ {/* Corner */} +
+ {/* Day headers */} + {DAY_JP_LIST.map((dayJp, di) => { + const dayEn = DAY_EN_LIST[di]; + const periods = HEADER_ID_TO_PERIODS.get(`${dayEn}-all`) ?? []; + const isToday = dayEn === TODAY_EN; + return ( + togglePeriod(periods)} + > + {dayJp} + + ); + })} + {/* Time rows */} + {TIME_LIST.map((time) => ( + + togglePeriod(HEADER_ID_TO_PERIODS.get(`all-${time}`) ?? [])} + > + {time} + + {DAY_JP_LIST.map((dayJp) => ( +
+ +
+ ))} +
+ ))} + {/* 集中 */} togglePeriod(HEADER_ID_TO_PERIODS.get(`all-${time}`) ?? [])} + onClick={() => togglePeriod(['集中'])} > - {time} + 集 - {DAY_JP_LIST.map((dayJp) => ( -
- -
- ))} - - ))} - {/* 集中 */} - togglePeriod(['集中'])} - > - 集 - -
- -
-
+
+ +
+
- - - - + + + + + + )} ); } diff --git a/src/components/main/ConditionFilter.module.css b/src/components/main/ConditionFilter.module.css index 0cc7213..16f30ac 100644 --- a/src/components/main/ConditionFilter.module.css +++ b/src/components/main/ConditionFilter.module.css @@ -1,45 +1,15 @@ -.semesterGrid { +.filterGrid { display: grid; - grid-template-columns: repeat(4, 2.5rem); - grid-template-rows: repeat(2, 2.5rem); - grid-auto-flow: column dense; - gap: 4px; + grid-template-columns: 1fr 1fr; + gap: 16px 32px; } -/* S_, A_ span 2 columns */ -.semesterGrid > :nth-child(3n+1) { - grid-column: span 2; +.fullWidth { + grid-column: 1 / -1; } -.categoryGrid { - display: grid; - grid-template-columns: repeat(8, 2.5rem); - gap: 4px; -} - -/* foundation, requirement, thematic, intermediate span 2 */ -.categoryGrid > :nth-child(-n+4) { - grid-column: span 2; -} - -@media (max-width: 470px) { - .categoryGrid { - grid-template-columns: repeat(4, 2.5rem); +@media (max-width: 480px) { + .filterGrid { + grid-template-columns: 1fr; } } - -/* Ternary (evaluation) */ -.ternaryGroup { - display: grid; - grid-template-columns: 3rem 1.5rem 1.5rem; - align-items: center; - gap: 4px; -} - -.evalHeader { - display: grid; - grid-template-columns: 3rem 1.5rem 1.5rem; - gap: 4px; - margin-bottom: 2px; -} - diff --git a/src/components/main/ConditionFilter.tsx b/src/components/main/ConditionFilter.tsx index 1d7e56c..824d6f7 100644 --- a/src/components/main/ConditionFilter.tsx +++ b/src/components/main/ConditionFilter.tsx @@ -1,161 +1,281 @@ import { useCallback } from 'react'; -import { Group, Stack, Text, Button, ActionIcon, Box } from '@mantine/core'; -import { IconCheck, IconX } from '@tabler/icons-react'; +import { + Checkbox, Stack, Group, Text, Box, Switch, TextInput, Button, Collapse, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { useAppContext } from '../../context/AppContext.tsx'; -import type { TernaryState } from '../../types.ts'; -import { NAME_TABLE, SEMESTER_OPTIONS, EVALUATION_OPTIONS, CATEGORY_OPTIONS, REGISTRATION_OPTIONS } from '../../lib/constants.ts'; +import { useSearchContext } from '../../context/SearchContext.tsx'; import classes from './ConditionFilter.module.css'; -function BinaryBtn({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { - return ( - - ); +type TreeNode = { + key: string; + label: string; + virtual?: boolean; + children?: TreeNode[]; +}; + +function getAllFilterKeys(node: TreeNode): string[] { + const self = node.virtual ? [] : [node.key]; + return [...self, ...(node.children?.flatMap(getAllFilterKeys) ?? [])]; } -function TernaryGroup({ label, value, onChange }: { - label: string; - value: TernaryState; - onChange: (v: TernaryState) => void; +type NodeState = 'checked' | 'unchecked' | 'indeterminate'; + +function getNodeState(node: TreeNode, values: Record): NodeState { + const keys = getAllFilterKeys(node); + if (!keys.length) return 'unchecked'; + const checkedCount = keys.filter((k) => values[k]).length; + if (checkedCount === 0) return 'unchecked'; + if (checkedCount === keys.length) return 'checked'; + return 'indeterminate'; +} + +function applyToggle( + node: TreeNode, + values: Record, +): Record { + const keys = getAllFilterKeys(node); + const newVal = getNodeState(node, values) !== 'checked'; + return { ...values, ...Object.fromEntries(keys.map((k) => [k, newVal])) }; +} + +type FlatNode = { node: TreeNode; depth: number }; + +function flattenTree(nodes: TreeNode[], depth = 0): FlatNode[] { + return nodes.flatMap((node) => [ + { node, depth }, + ...flattenTree(node.children ?? [], depth + 1), + ]); +} + +function CheckboxTree({ + nodes, + values, + onToggle, +}: { + nodes: TreeNode[]; + values: Record; + onToggle: (node: TreeNode) => void; }) { - const handleMust = () => onChange(value === true ? null : true); - const handleReject = () => onChange(value === false ? null : false); + const flat = flattenTree(nodes); return ( -
- {label} - - {value === true && } - - - {value === false && } - -
+ + {flat.map(({ node, depth }) => { + const state = getNodeState(node, values); + return ( + onToggle(node)} + ml={depth * 16} + size="xs" + /> + ); + })} + ); } -function FilterSection({ title, children }: { title: string; children: React.ReactNode }) { +const CATEGORY_TREE: TreeNode[] = [ + { + key: '__cat_all__', + label: 'すべて', + virtual: true, + children: [ + { key: 'foundation', label: '基礎' }, + { key: 'requirement', label: '要求' }, + { key: 'thematic', label: '主題' }, + { key: 'intermediate', label: '展開' }, + { + key: '__sogo__', + label: '総合', + virtual: true, + children: [ + { key: 'L', label: 'L' }, + { key: 'A', label: 'A' }, + { key: 'B', label: 'B' }, + { key: 'C', label: 'C' }, + { key: 'D', label: 'D' }, + { key: 'E', label: 'E' }, + { key: 'F', label: 'F' }, + ], + }, + ], + }, +]; + +const EVAL_TREE: TreeNode[] = [ + { + key: '__eval_all__', + label: 'すべて', + virtual: true, + children: [ + { key: 'exam', label: '試験' }, + { key: 'paper', label: 'レポ' }, + { key: 'attendance', label: '出席' }, + { key: 'participation', label: '平常' }, + { key: 'other', label: 'その他' }, + ], + }, +]; + +const SEMESTER_TREE: TreeNode[] = [ + { + key: 'S_', + label: 'S', + children: [ + { key: 'S1', label: 'S1' }, + { key: 'S2', label: 'S2' }, + ], + }, + { + key: 'A_', + label: 'A', + children: [ + { key: 'A1', label: 'A1' }, + { key: 'A2', label: 'A2' }, + ], + }, +]; + +function SectionTitle({ children }: { children: React.ReactNode }) { return ( - - - {title} - -
{children}
-
+ + {children} + ); } export function ConditionFilter() { const { condition, setCondition } = useAppContext(); + const { + freeword, setFreeword, + searchAll, setSearchAll, + availableOnly, setAvailableOnly, + inputHistory, setInputHistory, + } = useSearchContext(); + const [advancedOpened, { toggle: toggleAdvanced }] = useDisclosure(false); - const toggleSemester = useCallback((key: string) => { - setCondition((prev) => ({ - ...prev, - semester: { ...prev.semester, [key]: !prev.semester[key] }, - })); + const handleFreewordBlur = useCallback(() => { + if (!freeword) return; + setInputHistory([freeword, ...inputHistory.filter((h) => h !== freeword)].slice(0, 10)); + }, [freeword, inputHistory, setInputHistory]); + + const toggleCategoryNode = useCallback((node: TreeNode) => { + setCondition((prev) => ({ ...prev, category: applyToggle(node, prev.category) })); }, [setCondition]); - const toggleCategory = useCallback((key: string) => { - setCondition((prev) => ({ - ...prev, - category: { ...prev.category, [key]: !prev.category[key] }, - })); + const toggleEvalNode = useCallback((node: TreeNode) => { + setCondition((prev) => ({ ...prev, evaluation: applyToggle(node, prev.evaluation) })); }, [setCondition]); - const toggleRegistration = useCallback((key: string) => { - setCondition((prev) => ({ - ...prev, - registration: { ...prev.registration, [key]: !prev.registration[key] }, - })); + const toggleSemesterNode = useCallback((node: TreeNode) => { + setCondition((prev) => ({ ...prev, semester: applyToggle(node, prev.semester) })); }, [setCondition]); - const setEvaluation = useCallback((key: string, v: TernaryState) => { + const toggleRegistration = useCallback((key: string) => { setCondition((prev) => ({ ...prev, - evaluation: { ...prev.evaluation, [key]: v }, + registration: { ...prev.registration, [key]: !prev.registration[key] }, })); }, [setCondition]); return ( - - -
- {SEMESTER_OPTIONS.map((key) => ( - toggleSemester(key)} +
+ {/* フリーワード */} + + フリーワード + + setFreeword(e.target.value)} + onBlur={handleFreewordBlur} + placeholder="講義名・教員名など" + flex={1} + size="xs" + /> + + {inputHistory.map((h, i) => + + + + + {/* 種別 */} + + 種別 + + + + {/* 評価 */} + + 評価 + + + + {/* 詳細な設定 */} + + + {advancedOpened ? '▲' : '▶'} 詳細な設定 + + + + setAvailableOnly(e.currentTarget.checked)} + color="syllabusGreen" + size="xs" /> - ))} -
- - - -
- - 含む - 除外 -
- - {EVALUATION_OPTIONS.map((key) => ( - setEvaluation(key, v)} + toggleRegistration('registered')} + color="syllabusGreen" + size="xs" /> - ))} - -
- - -
- {CATEGORY_OPTIONS.map((key) => ( - toggleCategory(key)} + toggleRegistration('unregistered')} + color="syllabusGreen" + size="xs" /> - ))} -
-
- - - - {REGISTRATION_OPTIONS.map((key) => ( - toggleRegistration(key)} + + + + + {/* 学期 */} + + 学期 + + {SEMESTER_TREE.map((root) => ( + ))} - - + +
); } diff --git a/src/components/main/LectureTable.tsx b/src/components/main/LectureTable.tsx index 9386edc..15b372f 100644 --- a/src/components/main/LectureTable.tsx +++ b/src/components/main/LectureTable.tsx @@ -1,9 +1,16 @@ -import { SegmentedControl, Text, Paper, Table, Button, Box } from '@mantine/core'; +import { ActionIcon, Group, SegmentedControl, Text, Paper, Table, Button, Box } from '@mantine/core'; +import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from '@tabler/icons-react'; import { useAppContext } from '../../context/AppContext.tsx'; import { useSearchContext } from '../../context/SearchContext.tsx'; import classes from './LectureTable.module.css'; import type { Lecture } from '../../types.ts'; +interface Props { + collapsed: boolean; + onToggle: () => void; + isDesktop: boolean; +} + function RegisterButton({ lecture }: { lecture: Lecture }) { const { registeredCodes, toggleRegister } = useAppContext(); const isReg = registeredCodes.has(lecture.code); @@ -51,42 +58,80 @@ function LectureRow({ lecture }: { lecture: Lecture }) { ); } -export function LectureTable() { +export function LectureTable({ collapsed, onToggle, isDesktop }: Props) { const { displayTab, setDisplayTab, filteredLectures } = useSearchContext(); + if (collapsed && isDesktop) { + return ( + + + + + + 検索結果 + + + ); + } + return ( - - setDisplayTab(v as 'searched' | 'registered')} - size="sm" - data={[ - { value: 'searched', label: `検索結果 (${filteredLectures.length}件)` }, - { value: 'registered', label: '登録中の講義' }, - ]} - /> - - - - {filteredLectures.map((lec) => ( - - ))} - {filteredLectures.length === 0 && ( - - - 該当する講義がありません - - - )} - -
+ + {!collapsed && ( + setDisplayTab(v as 'searched' | 'registered')} + size="sm" + data={[ + { value: 'searched', label: `検索結果 (${filteredLectures.length}件)` }, + { value: 'registered', label: '登録中の講義' }, + ]} + /> + )} + {collapsed && ( + 検索結果 + )} + + {isDesktop + ? + : collapsed + ? + : + } + + + {!collapsed && ( + + + + {filteredLectures.map((lec) => ( + + ))} + {filteredLectures.length === 0 && ( + + + 該当する講義がありません + + + )} + +
+
+ )}
); } diff --git a/src/components/main/MainContent.tsx b/src/components/main/MainContent.tsx index f77c700..d181261 100644 --- a/src/components/main/MainContent.tsx +++ b/src/components/main/MainContent.tsx @@ -1,24 +1,79 @@ -import { Box, Grid } from '@mantine/core'; +import { useState } from 'react'; +import { Box } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; import { Calendar } from './Calendar.tsx'; import { SearchPanel } from './SearchPanel.tsx'; import { LectureTable } from './LectureTable.tsx'; +type Panel = 'search' | 'results' | 'calendar'; +const FLEX: Record = { search: 3, results: 5, calendar: 4 }; +const COLLAPSED_WIDTH = 44; + export function MainContent() { + const [collapsed, setCollapsed] = useState>(new Set()); + const isDesktop = useMediaQuery('(min-width: 48em)', true); + + const toggle = (panel: Panel) => { + setCollapsed(prev => { + const next = new Set(prev); + if (next.has(panel)) next.delete(panel); + else next.add(panel); + return next; + }); + }; + + const colStyle = (panel: Panel): React.CSSProperties => { + if (!isDesktop) return {}; + const isCollapsed = collapsed.has(panel); + return { + flex: isCollapsed ? `0 0 ${COLLAPSED_WIDTH}px` : FLEX[panel], + transition: 'flex 0.25s ease', + minWidth: 0, + overflow: 'hidden', + height: '100%', + }; + }; + return ( - - - - - - - - - + + toggle('search')} + isDesktop={isDesktop} + /> + + + toggle('results')} + isDesktop={isDesktop} + /> + + + toggle('calendar')} + isDesktop={isDesktop} + /> + ); } diff --git a/src/components/main/PeriodCell.tsx b/src/components/main/PeriodCell.tsx index 8d910db..fabc973 100644 --- a/src/components/main/PeriodCell.tsx +++ b/src/components/main/PeriodCell.tsx @@ -16,7 +16,7 @@ export function PeriodCell({ period, isIntensive = false }: PeriodCellProps) { const stream = personal?.stream ?? ''; return ( -
); })} - +
); } diff --git a/src/components/main/SearchPanel.tsx b/src/components/main/SearchPanel.tsx index 35b05d2..d841fc4 100644 --- a/src/components/main/SearchPanel.tsx +++ b/src/components/main/SearchPanel.tsx @@ -1,73 +1,67 @@ import { useCallback } from 'react'; -import { Switch, Text, Button, Group, Paper, TextInput, Box } from '@mantine/core'; +import { ActionIcon, Button, Group, Paper, Text } from '@mantine/core'; +import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from '@tabler/icons-react'; import { useAppContext, defaultCondition } from '../../context/AppContext.tsx'; -import { useSearchContext } from '../../context/SearchContext.tsx'; import { ConditionFilter } from './ConditionFilter.tsx'; -export function SearchPanel() { - const { setCondition, personal } = useAppContext(); - const { - freeword, setFreeword, - searchAll, setSearchAll, - availableOnly, setAvailableOnly, - inputHistory, setInputHistory, - } = useSearchContext(); +interface Props { + collapsed: boolean; + onToggle: () => void; + isDesktop: boolean; +} - const handleFreewordBlur = useCallback(() => { - if (!freeword) return; - setInputHistory([freeword, ...inputHistory.filter((h) => h !== freeword)].slice(0, 10)); - }, [freeword, inputHistory, setInputHistory]); +export function SearchPanel({ collapsed, onToggle, isDesktop }: Props) { + const { setCondition, personal } = useAppContext(); const resetCondition = useCallback(() => { setCondition(defaultCondition(personal?.stream ?? 'default')); }, [setCondition, personal]); - return ( - - 検索条件 - - setAvailableOnly(e.currentTarget.checked)} - color="syllabusGreen" - mb="sm" - /> - - - フリーワード - - setFreeword(e.target.value)} - onBlur={handleFreewordBlur} - placeholder="講義名・教員名など" - w="auto" - /> - - {inputHistory.map((h, i) => - - - + if (collapsed && isDesktop) { + return ( + + + + + + 検索条件 + + + ); + } - - - - + return ( + + + 検索条件 + + {isDesktop + ? + : collapsed + ? + : + } + + {!collapsed && ( + <> + + + + + + )} ); } diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 92a7e68..ef7c05b 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -15,7 +15,7 @@ export function defaultCondition(stream: string): Condition { const suffix = LAST_UPDATED.slice(-1); return { semester: Object.fromEntries(SEMESTER_OPTIONS.map((o) => [o, o.includes(suffix)])), - evaluation: Object.fromEntries(EVALUATION_OPTIONS.map((o) => [o, null])), + evaluation: Object.fromEntries(EVALUATION_OPTIONS.map((o) => [o, false])), category: Object.fromEntries( CATEGORY_OPTIONS.map((o) => [ o, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ff71e16..fd6826a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -69,9 +69,10 @@ export const NAME_TABLE: Record = { registration: '登録', unregistered: '未登録', registered: '登録済', + other: 'その他', }; export const SEMESTER_OPTIONS = ['S_', 'S1', 'S2', 'A_', 'A1', 'A2'] as const; -export const EVALUATION_OPTIONS = ['exam', 'paper', 'attendance', 'participation'] as const; +export const EVALUATION_OPTIONS = ['exam', 'paper', 'attendance', 'participation', 'other'] as const; export const CATEGORY_OPTIONS = ['foundation', 'requirement', 'thematic', 'intermediate', 'L', 'A', 'B', 'C', 'D', 'E', 'F'] as const; export const REGISTRATION_OPTIONS = ['unregistered', 'registered'] as const; diff --git a/src/lib/filters.ts b/src/lib/filters.ts index ff8ee16..2edf792 100644 --- a/src/lib/filters.ts +++ b/src/lib/filters.ts @@ -38,8 +38,7 @@ export function buildLectureFilter(args: { const skipSemester = semesterOpts.every(([, v]) => !v) || semesterOpts.every(([, v]) => v); const skipCategory = categoryOpts.every(([, v]) => !v) || categoryOpts.every(([, v]) => v); - const skipEvalMust = evalOpts.every(([, v]) => !v); - const skipEvalReject = evalOpts.every(([, v]) => v ?? true); + const skipEval = !evalOpts.some(([, v]) => v) || evalOpts.every(([, v]) => v); const skipRegistration = regOpts.every(([, v]) => !v) || regOpts.every(([, v]) => v); const skipPeriodsFilter = selectedPeriods.size === 0; @@ -78,18 +77,18 @@ export function buildLectureFilter(args: { if (!match) return false; } - if (!skipEvalMust) { - const match = evalOpts.some( - ([k, v]) => v === true && lecture.shortenedEvaluation.includes(NAME_TABLE[k]) - ); + if (!skipEval) { + const KNOWN_EVAL_NAMES = ['試験', 'レポ', '出席', '平常']; + const match = evalOpts + .filter(([, v]) => v) + .some(([k]) => { + if (k === 'other') { + return !KNOWN_EVAL_NAMES.some((n) => lecture.shortenedEvaluation.includes(n)); + } + return lecture.shortenedEvaluation.includes(NAME_TABLE[k]); + }); if (!match) return false; } - if (!skipEvalReject) { - const bad = evalOpts.some( - ([k, v]) => v === false && lecture.shortenedEvaluation.includes(NAME_TABLE[k]) - ); - if (bad) return false; - } if (!skipRegistration) { const match = regOpts.some( diff --git a/src/types.ts b/src/types.ts index f9223ff..12d9185 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,11 +41,9 @@ export interface PersonalStatus { autofillCompulsory: boolean; } -export type TernaryState = true | false | null; - export interface Condition { semester: Record; - evaluation: Record; + evaluation: Record; category: Record; registration: Record; } From e1673ac6c5cdd4df9848afd27226fb3913c87ae8 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Thu, 28 May 2026 02:25:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/text.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/lib/text.test.ts diff --git a/src/lib/text.test.ts b/src/lib/text.test.ts new file mode 100644 index 0000000..6cd2322 --- /dev/null +++ b/src/lib/text.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { normalize, parseKeywords, toSearch } from './text.ts'; + +describe('normalize', () => { + it('全角英数字を半角に変換する', () => { + expect(normalize('A1')).toBe('A1'); + }); + + it('全角スペース・連続スペースを半角1つに正規化する', () => { + expect(normalize('foo bar baz')).toBe('foo bar baz'); + }); + + it('各種ダッシュ・ハイフンを統一する', () => { + expect(normalize('A‐B―C−D')).toBe('A-B-C-D'); + }); + + it('null / undefined を空文字として扱う', () => { + expect(normalize(null)).toBe(''); + expect(normalize(undefined)).toBe(''); + }); + + it('前後の空白を除去する', () => { + expect(normalize(' hello ')).toBe('hello'); + }); +}); + +describe('toSearch', () => { + it('大文字を小文字に変換する', () => { + expect(toSearch('HELLO')).toBe('hello'); + }); + + it('長音符をハイフンに変換する', () => { + expect(toSearch('コーヒー')).toBe('コ-ヒ-'); + }); + + it('読点・句点を変換する', () => { + expect(toSearch('りんご、みかん。')).toBe('りんご,みかん.'); + }); +}); + +describe('parseKeywords', () => { + it('スペース区切りでキーワードを分割する', () => { + const [pos, neg] = parseKeywords('数学 物理'); + expect(pos).toEqual(['数学', '物理']); + expect(neg).toEqual([]); + }); + + it('- 始まりのキーワードを除外語として扱う', () => { + const [pos, neg] = parseKeywords('数学 -物理'); + expect(pos).toEqual(['数学']); + expect(neg).toEqual(['物理']); + }); + + it('空文字列は無視する', () => { + const [pos, neg] = parseKeywords(''); + expect(pos).toEqual([]); + expect(neg).toEqual([]); + }); + + it('- 単体は正のキーワードとして扱う(除外語は -foo 形式のみ)', () => { + const [pos, neg] = parseKeywords('-'); + expect(pos).toEqual(['-']); + expect(neg).toEqual([]); + }); +});