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
8 changes: 4 additions & 4 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

<head>
<meta charset="UTF-8" />
<title>イツヒマ</title>
<title>イツヒマ - 「いつヒマ?」で日程調整しよう</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:type" content="website" />
<meta property="og:title" content="イツヒマ" />
<meta property="og:description" content="「いつ暇?」で日程調整しよう" />
<meta property="og:image" content="https://itsuhima.utcode.net/og-image.webp" />
<meta property="og:title" content="イツヒマ - 「いつヒマ?」で日程調整しよう" />
<meta property="og:description" content="とりあえずみんなの空いている時間を訊いてから、何を何時間やるか決めたい。そんな仲間うちでの日程調整に最適なツールです。" />
<meta property="og:image" content="https://itsuhima.utcode.net/og-image.jpg" />
<meta property="og:site_name" content="イツヒマ" />
<meta property="og:locale" content="ja_JP" />
<meta name="twitter:card" content="summary_large_image" />
Expand Down
Binary file added client/public/mock-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/og-image.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed client/public/og-image.webp
Binary file not shown.
6 changes: 4 additions & 2 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { BrowserRouter, Outlet, Route, Routes } from "react-router";
import HomePage from "./pages/Home.tsx";
import LandingPage from "./pages/Landing.tsx";
import NotFoundPage from "./pages/NotFound.tsx";
import ProjectPage from "./pages/Project.tsx";
import RootPage from "./pages/Root.tsx";
import SubmissionPage from "./pages/eventId/Submission.tsx";

export default function App() {
return (
<BrowserRouter>
<Routes>
<Route index element={<RootPage />} />
<Route index element={<LandingPage />} />
<Route path="home" element={<HomePage />} />
<Route path="new" element={<ProjectPage />} />
<Route path=":eventId" element={<Outlet />}>
<Route index element={<SubmissionPage />} />
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function Header() {
return (
<div className="navbar bg-primary shadow-sm sticky top-0 left-0 z-50">
<div className="flex-1">
<NavLink className="flex text-2xl text-white items-center px-2 gap-1 font-mplus" to="/">
<NavLink className="flex text-2xl text-white items-center px-2 gap-1 font-mplus" to="/home">
<img src="/logo-white.svg" alt="logo" width={24} />
<span className="px-2">イツヒマ</span>
<span className="text-xs">(アルファ版)</span>
Expand Down
40 changes: 40 additions & 0 deletions client/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import type { ZodType, ZodTypeDef } from "zod";
import { API_ENDPOINT } from "./utils";

export function useData<O, I>(
url: string | null,
Expand Down Expand Up @@ -48,3 +49,42 @@ export function useData<O, I>(

return { data, loading, error, refetch: fetchData };
}

export function useAuth(): { isAuthenticated: boolean | null } {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);

useEffect(() => {
const controller = new AbortController();

const checkAuth = async () => {
try {
const res = await fetch(`${API_ENDPOINT}/projects/mine`, {
method: "GET",
credentials: "include",
signal: controller.signal,
});

if (res.ok) {
setIsAuthenticated(true);
} else if (res.status === 401 || res.status === 403) {
setIsAuthenticated(false);
} else {
setIsAuthenticated(false);
console.error(`Unexpected response: ${res.status} ${res.statusText}`);
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
setIsAuthenticated(false);
console.error("Auth check failed:", err);
}
};

checkAuth();

return () => controller.abort();
}, []);

return { isAuthenticated };
}
3 changes: 2 additions & 1 deletion client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
prefersdark: true; /* TODO: ダークモード対応 */
--color-primary: #0f82b1;
--color-primary-content: #ffffff;
--color-secondary: #ff48a0;
--color-secondary: #a6e3d8;
--color-secondary-content: #065f52;
}

.btn {
Expand Down
166 changes: 166 additions & 0 deletions client/src/pages/Home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { HiOutlineCalendar, HiOutlineCog, HiOutlinePlus, HiOutlineUser, HiOutlineUsers } from "react-icons/hi";
import { NavLink } from "react-router";
import { type InvolvedProjects, involvedProjectsResSchema } from "../../../common/schema";
import Header from "../components/Header";
import { useData } from "../hooks";
import { API_ENDPOINT } from "../utils";

export default function HomePage() {
const { data: involvedProjects, loading } = useData(`${API_ENDPOINT}/projects/mine`, involvedProjectsResSchema);

return (
<>
<Header />
{loading ? (
<div className="min-h-[calc(100dvh_-_64px)] bg-blue-50 flex items-center justify-center">
<div className="py-4">
<span className="loading loading-dots loading-md text-gray-400" />
</div>
</div>
) : involvedProjects ? (
<ProjectDashboard involvedProjects={involvedProjects} />
) : (
<div className="min-h-[calc(100dvh_-_64px)] bg-blue-50 flex items-center justify-center">
<EmptyState />
</div>
)}
</>
);
}

function ProjectDashboard({ involvedProjects }: { involvedProjects: InvolvedProjects }) {
const sortedProjects = [...involvedProjects].sort((a, b) => {
if (a.isHost !== b.isHost) {
return a.isHost ? -1 : 1;
}
return new Date(b.startDate).getTime() - new Date(a.startDate).getTime();
});

return (
<div className="min-h-[calc(100dvh_-_64px)] bg-blue-50">
<div className="container mx-auto px-4 py-8">
{/* Hero Section */}
<div className="text-center mb-12">
<div className="flex items-center justify-center mt-2 mb-6">
<img src="/logo.svg" alt="logo" width={48} className="mr-4" />
<h1 className="text-4xl text-primary font-mplus">イツヒマ</h1>
</div>
<p className="text-xl text-gray-600 mb-8">「いつヒマ?」で日程調整しよう</p>
<NavLink
to="/new"
className="btn btn-primary btn-lg px-8 py-4 text-lg shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
>
<HiOutlinePlus className="mr-2" size={20} />
新しいイベントを作成
</NavLink>
</div>

{involvedProjects.length > 0 ? (
<div className="space-y-8">
{/* All Projects */}
<section>
<h2 className="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<HiOutlineCalendar className="mr-3 text-gray-700" size={28} />
あなたのイベント
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{sortedProjects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</section>
</div>
) : (
<EmptyState />
)}
</div>
</div>
);
}

function ProjectCard({ project }: { project: InvolvedProjects[0] }) {
return (
<NavLink
to={`/${project.id}`}
className={`group relative block bg-white rounded-xl shadow-lg hover:shadow-xl
transition-all duration-300 transform hover:-translate-y-1 overflow-hidden
border-l-4 ${project.isHost ? "border-primary" : "border-secondary"}
focus:outline-none focus:ring-4 focus:ring-primary/20`}
aria-label={`「${project.name}」の詳細を見る`}
>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div className="flex-1 mr-2">
<h3 className="text-xl font-semibold text-gray-800 mb-2 break-words">{project.name}</h3>
<span className={`badge badge-sm ${project.isHost ? "badge-primary" : "badge-secondary"}`}>
{project.isHost ? (
<>
<HiOutlineUser size={12} />
<span>主催者</span>
</>
) : (
<>
<HiOutlineUsers size={12} />
<span>参加者</span>
</>
)}
</span>
</div>

{project.isHost && (
<NavLink
to={`/${project.id}/edit`}
onClick={(e) => e.stopPropagation()}
className="btn btn-ghost btn-sm px-3 py-1 text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-all"
>
<HiOutlineCog size={14} />
<span className="text-xs">管理</span>
</NavLink>
)}
</div>

<div className="flex items-center text-gray-600 mb-4">
<HiOutlineCalendar className="mr-2" size={16} />
<span className="text-sm">
{formatDate(project.startDate.toLocaleDateString())} ~{formatDate(project.endDate.toLocaleDateString())}
</span>
</div>
</div>

<span
className={`absolute bottom-4 right-4 ${project.isHost ? "text-primary" : "text-secondary"} text-2xl pointer-events-none
group-hover:translate-x-1 transition-transform`}
aria-hidden="true"
>
&rsaquo;
</span>
</NavLink>
);
}

function EmptyState() {
return (
<div className="text-center py-16">
<div className="mb-8">
<div className="w-32 h-32 mx-auto mb-6 bg-gray-100 rounded-full flex items-center justify-center">
<HiOutlineCalendar className="text-gray-400" size={64} />
</div>
<h3 className="text-2xl font-semibold text-gray-800 mb-3">まだイベントがありません</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">イベントを作成して、日程調整を始めましょう</p>
</div>
<NavLink
to="/new"
className="btn btn-primary btn-lg px-8 py-4 text-lg shadow-lg hover:shadow-xl transition-all duration-300"
>
<HiOutlinePlus className="mr-2" size={20} />
イベントを作成する
</NavLink>
</div>
);
}

// ---------- Utility ----------
const formatDate = (isoDate: string) => {
const date = new Date(isoDate);
return date.toLocaleDateString("ja-JP");
};
Loading