2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MacのカレンダーみたいなUIを作りたい!

Posted at

はじめに

みなさん一度はMacのカレンダーみたいなUIを作ってみたいと思ったことはあるはず(知らんけど)
スクリーンショット 2025-03-04 18.52.46.png
スクリーンショット 2025-03-04 18.54.28.png
スクリーンショット 2025-03-04 18.54.36.png

そこで、今回は、簡単にこんな感じのカレンダーUIを作成する方法をご紹介します。

手順

1. スクショをclaudeに貼り付ける

撮ったスクショを3枚ともclaudeに貼り付けて、
「Next.jsとTypescriptを用いて添付したようなカレンダーを作成して
あと、週と月を選択した時は添付した画像のように見た目が変わるようにして」
と命令してみました。
その結果得られたコードが以下

// まず、プロジェクトのセットアップ方法を示します
/*
以下のコマンドでNext.jsプロジェクトを作成します:
npx create-next-app@latest calendar-app --typescript
cd calendar-app

以下の依存関係をインストール:
npm install date-fns
*/

// 以下のファイル構成でアプリを実装します:

// 1. src/types/index.ts
export type CalendarEvent = {
  id: string;
  title: string;
  date: Date;
  startTime?: string;
  endTime?: string;
  color?: string;
  isHoliday?: boolean;
};

export type ViewType = 'day' | 'week' | 'month';

// 2. src/components/Header.tsx
import React from 'react';
import { ViewType } from '../types';

type HeaderProps = {
  currentDate: Date;
  view: ViewType;
  onViewChange: (view: ViewType) => void;
  onDateChange: (date: Date) => void;
};

const Header: React.FC<HeaderProps> = ({ currentDate, view, onViewChange, onDateChange }) => {
  const formatYearMonth = (date: Date) => {
    return `${date.getFullYear()}${date.getMonth() + 1}月`;
  };
  
  const formatFullDate = (date: Date) => {
    return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}日`;
  };
  
  const goToPrevious = () => {
    const newDate = new Date(currentDate);
    if (view === 'day') {
      newDate.setDate(newDate.getDate() - 1);
    } else if (view === 'week') {
      newDate.setDate(newDate.getDate() - 7);
    } else {
      newDate.setMonth(newDate.getMonth() - 1);
    }
    onDateChange(newDate);
  };
  
  const goToNext = () => {
    const newDate = new Date(currentDate);
    if (view === 'day') {
      newDate.setDate(newDate.getDate() + 1);
    } else if (view === 'week') {
      newDate.setDate(newDate.getDate() + 7);
    } else {
      newDate.setMonth(newDate.getMonth() + 1);
    }
    onDateChange(newDate);
  };
  
  const goToToday = () => {
    onDateChange(new Date());
  };
  
  return (
    <header className="flex items-center justify-between p-4 border-b">
      <div className="traffic-lights flex gap-2">
        <div className="w-3 h-3 rounded-full bg-red-500"></div>
        <div className="w-3 h-3 rounded-full bg-yellow-400"></div>
        <div className="w-3 h-3 rounded-full bg-green-500"></div>
      </div>
      
      <h1 className="text-2xl font-bold">
        {view === 'day' ? formatFullDate(currentDate) : formatYearMonth(currentDate)}
      </h1>
      
      <div className="flex items-center gap-4">
        <div className="navigation flex items-center gap-2">
          <button onClick={goToPrevious} className="p-1">
            <span className="text-lg"></span>
          </button>
          <button onClick={goToToday} className="px-3 py-1 rounded bg-gray-100 text-sm">
            今日
          </button>
          <button onClick={goToNext} className="p-1">
            <span className="text-lg"></span>
          </button>
        </div>
        
        <div className="view-selector flex border rounded overflow-hidden">
          <button
            onClick={() => onViewChange('day')}
            className={`px-4 py-1 ${view === 'day' ? 'bg-white' : 'bg-gray-100'}`}
          >
            
          </button>
          <button
            onClick={() => onViewChange('week')}
            className={`px-4 py-1 ${view === 'week' ? 'bg-white' : 'bg-gray-100'}`}
          >
            
          </button>
          <button
            onClick={() => onViewChange('month')}
            className={`px-4 py-1 ${view === 'month' ? 'bg-white' : 'bg-gray-100'}`}
          >
            
          </button>
        </div>
      </div>
    </header>
  );
};

export default Header;

// 3. src/components/DayView.tsx
import React from 'react';
import { format } from 'date-fns';
import { ja } from 'date-fns/locale';
import { CalendarEvent } from '../types';

type DayViewProps = {
  currentDate: Date;
  events: CalendarEvent[];
};

const DayView: React.FC<DayViewProps> = ({ currentDate, events }) => {
  const hours = Array.from({ length: 17 }, (_, i) => i + 4); // 4時から20時まで
  const dayEvents = events.filter(
    event => event.date.toDateString() === currentDate.toDateString()
  );
  
  const formatWeekDay = (date: Date) => {
    const weekDays = ['', '', '', '', '', '', ''];
    return `${weekDays[date.getDay()]}曜日`;
  };
  
  const currentTime = new Date();
  const currentHour = currentTime.getHours();
  const currentMinute = currentTime.getMinutes();
  const showCurrentTimeLine = currentTime.toDateString() === currentDate.toDateString();
  
  return (
    <div className="flex-1 flex flex-col h-full overflow-hidden">
      <div className="p-4 border-b">
        <h2 className="text-xl font-bold">
          {format(currentDate, 'yyyy年M月d日')}
        </h2>
        <p className="text-gray-600">{formatWeekDay(currentDate)}</p>
      </div>
      
      <div className="flex-1 overflow-y-auto">
        {hours.map(hour => {
          const timeString = `${hour}:00`;
          const isCurrentHour = showCurrentTimeLine && hour === currentHour;
          
          // 現在時刻の線の位置を計算
          let currentTimePercentage = 0;
          if (isCurrentHour) {
            currentTimePercentage = (currentMinute / 60) * 100;
          }
          
          return (
            <div key={hour} className="relative min-h-16 border-b flex">
              <div className="w-20 py-2 text-right pr-3 text-gray-500 flex-shrink-0">
                {timeString}
              </div>
              <div className="flex-1 border-l relative">
                {isCurrentHour && (
                  <div 
                    className="absolute left-0 right-0 z-10"
                    style={{ top: `${currentTimePercentage}%` }}
                  >
                    <div className="h-0.5 bg-red-500 relative">
                      <div className="absolute -left-1 -top-3 bg-red-500 text-white px-1 py-0.5 rounded text-xs">
                        {format(currentTime, 'HH:mm')}
                      </div>
                    </div>
                  </div>
                )}
                
                {dayEvents
                  .filter(event => {
                    if (!event.startTime) return false;
                    const [eventHour] = event.startTime.split(':').map(Number);
                    return eventHour === hour;
                  })
                  .map(event => (
                    <div
                      key={event.id}
                      className={`absolute px-2 py-1 rounded m-1 ${
                        event.isHoliday ? 'bg-purple-200' : event.color || 'bg-blue-100'
                      }`}
                    >
                      {event.title}
                    </div>
                  ))}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default DayView;

// 4. src/components/WeekView.tsx
import React from 'react';
import { format, addDays, startOfWeek } from 'date-fns';
import { ja } from 'date-fns/locale';
import { CalendarEvent } from '../types';

type WeekViewProps = {
  currentDate: Date;
  events: CalendarEvent[];
};

const WeekView: React.FC<WeekViewProps> = ({ currentDate, events }) => {
  const hours = Array.from({ length: 17 }, (_, i) => i + 4); // 4時から20時まで
  
  // 週の開始日(日曜日)を取得
  const weekStart = startOfWeek(currentDate, { weekStartsOn: 0 });
  // 1週間分の日付を生成
  const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
  
  const formatDayHeader = (date: Date) => {
    const day = date.getDate();
    const weekDayChar = ['', '', '', '', '', '', ''][date.getDay()];
    const isToday = new Date().toDateString() === date.toDateString();
    const isCurrentMonth = date.getMonth() === currentDate.getMonth();
    
    return (
      <div className={`text-center py-2 ${isToday ? 'bg-red-500 text-white' : ''} ${!isCurrentMonth ? 'text-gray-400' : ''}`}>
        {day}({weekDayChar})
      </div>
    );
  };
  
  const getEventsForDay = (date: Date) => {
    return events.filter(event => event.date.toDateString() === date.toDateString());
  };
  
  const currentTime = new Date();
  const currentHour = currentTime.getHours();
  const currentMinute = currentTime.getMinutes();
  const showCurrentTimeLine = currentTime.toDateString() >= weekDays[0].toDateString() && 
                            currentTime.toDateString() <= weekDays[6].toDateString();
  
  return (
    <div className="flex-1 flex flex-col h-full overflow-hidden">
      <div className="flex border-b">
        <div className="w-20 flex-shrink-0 border-r">
          <div className="text-center py-2 text-gray-500">終日</div>
        </div>
        {weekDays.map((day, idx) => (
          <div key={idx} className="flex-1 border-r">
            {formatDayHeader(day)}
          </div>
        ))}
      </div>
      
      <div className="flex-1 overflow-y-auto">
        {/* 終日イベント行 */}
        <div className="flex border-b min-h-16">
          <div className="w-20 flex-shrink-0 border-r">
            <div className="py-2 text-right pr-3 text-gray-500">終日</div>
          </div>
          {weekDays.map((day, dayIdx) => (
            <div key={dayIdx} className="flex-1 border-r p-1">
              {getEventsForDay(day)
                .filter(event => !event.startTime)
                .map(event => (
                  <div
                    key={event.id}
                    className={`px-2 py-1 rounded mb-1 ${
                      event.isHoliday ? 'bg-purple-200' : event.color || 'bg-blue-100'
                    }`}
                  >
                    {event.isHoliday && ''}
                    {event.title}
                  </div>
                ))}
            </div>
          ))}
        </div>
        
        {/* 時間ごとの行 */}
        {hours.map(hour => {
          const timeString = `${hour}:00`;
          const isCurrentHour = showCurrentTimeLine && hour === currentHour;
          
          // 現在時刻の線の位置を計算
          let currentTimePercentage = 0;
          if (isCurrentHour) {
            currentTimePercentage = (currentMinute / 60) * 100;
          }
          
          return (
            <div key={hour} className="flex border-b min-h-16">
              <div className="w-20 flex-shrink-0 border-r">
                <div className="py-2 text-right pr-3 text-gray-500">{timeString}</div>
              </div>
              
              {weekDays.map((day, dayIdx) => {
                const isToday = day.toDateString() === currentTime.toDateString();
                
                return (
                  <div key={dayIdx} className="flex-1 border-r relative">
                    {isToday && isCurrentHour && (
                      <div 
                        className="absolute left-0 right-0 z-10"
                        style={{ top: `${currentTimePercentage}%` }}
                      >
                        <div className="h-0.5 bg-red-500 relative">
                          <div className="absolute -left-1 -top-3 bg-red-500 text-white px-1 py-0.5 rounded text-xs">
                            {format(currentTime, 'HH:mm')}
                          </div>
                        </div>
                      </div>
                    )}
                    
                    {getEventsForDay(day)
                      .filter(event => {
                        if (!event.startTime) return false;
                        const [eventHour] = event.startTime.split(':').map(Number);
                        return eventHour === hour;
                      })
                      .map(event => (
                        <div
                          key={event.id}
                          className={`absolute px-2 py-1 rounded m-1 ${
                            event.color || 'bg-blue-100'
                          }`}
                        >
                          {event.title}
                        </div>
                      ))}
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default WeekView;

// 5. src/components/MonthView.tsx
import React from 'react';
import { 
  format, 
  startOfMonth, 
  endOfMonth, 
  startOfWeek, 
  endOfWeek, 
  addDays,
  isSameMonth,
  isSameDay
} from 'date-fns';
import { CalendarEvent } from '../types';

type MonthViewProps = {
  currentDate: Date;
  events: CalendarEvent[];
};

const MonthView: React.FC<MonthViewProps> = ({ currentDate, events }) => {
  const getDaysInMonth = (date: Date) => {
    const start = startOfWeek(startOfMonth(date), { weekStartsOn: 0 });
    const end = endOfWeek(endOfMonth(date), { weekStartsOn: 0 });
    
    const days = [];
    let day = start;
    
    while (day <= end) {
      days.push(day);
      day = addDays(day, 1);
    }
    
    return days;
  };
  
  const days = getDaysInMonth(currentDate);
  const today = new Date();
  
  // 週ごとに日付を分割
  const weeks = [];
  for (let i = 0; i < days.length; i += 7) {
    weeks.push(days.slice(i, i + 7));
  }
  
  const getEventsForDay = (date: Date) => {
    return events.filter(event => isSameDay(event.date, date));
  };
  
  return (
    <div className="flex-1 overflow-auto">
      <div className="grid grid-cols-7 border-b">
        {['', '', '', '', '', '', ''].map(day => (
          <div key={day} className="p-2 text-center">{day}</div>
        ))}
      </div>
      
      <div className="grid grid-cols-7 auto-rows-fr">
        {weeks.map((week, weekIdx) => (
          <React.Fragment key={weekIdx}>
            {week.map((day, dayIdx) => {
              const dayEvents = getEventsForDay(day);
              const isToday = isSameDay(day, today);
              const isCurrentMonth = isSameMonth(day, currentDate);
              
              return (
                <div 
                  key={dayIdx} 
                  className={`border min-h-20 p-1 ${
                    !isCurrentMonth ? 'text-gray-400' : ''
                  }`}
                >
                  <div className={`text-right ${isToday ? 'bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center ml-auto' : ''}`}>
                    {day.getDate()}
                  </div>
                  
                  <div className="mt-1">
                    {dayEvents.map(event => (
                      <div
                        key={event.id}
                        className={`px-2 py-0.5 rounded mb-1 text-sm truncate ${
                          event.isHoliday ? 'bg-purple-200' : event.color || 'bg-blue-100'
                        }`}
                      >
                        {event.isHoliday && ''}
                        {event.title}
                      </div>
                    ))}
                  </div>
                </div>
              );
            })}
          </React.Fragment>
        ))}
      </div>
    </div>
  );
};

export default MonthView;

// 6. src/pages/index.tsx
import { useState, useEffect } from 'react';
import Head from 'next/head';
import Header from '../components/Header';
import DayView from '../components/DayView';
import WeekView from '../components/WeekView';
import MonthView from '../components/MonthView';
import { ViewType, CalendarEvent } from '../types';

export default function Home() {
  const [currentDate, setCurrentDate] = useState(new Date());
  const [view, setView] = useState<ViewType>('day');
  const [events, setEvents] = useState<CalendarEvent[]>([]);
  
  useEffect(() => {
    // サンプルイベントを設定
    const today = new Date();
    const sampleEvents: CalendarEvent[] = [
      {
        id: '1',
        title: '天皇誕生日',
        date: new Date(2025, 1, 23), // 2月23日
        isHoliday: true
      },
      {
        id: '2',
        title: '振替休日',
        date: new Date(2025, 1, 24), // 2月24日
        isHoliday: true
      },
      {
        id: '3',
        title: '建国記念の日',
        date: new Date(2025, 1, 11), // 2月11日
        isHoliday: true
      },
      {
        id: '4',
        title: 'レバテック 面談',
        date: new Date(2025, 1, 5), // 2月5日
        startTime: '10:00',
        endTime: '11:00',
        color: 'bg-blue-100'
      }
    ];
    
    setEvents(sampleEvents);
  }, []);
  
  return (
    <>
      <Head>
        <title>カレンダーアプリ</title>
        <meta name="description" content="Next.js Calendar App" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      
      <main className="flex flex-col h-screen">
        <Header 
          currentDate={currentDate}
          view={view}
          onViewChange={setView}
          onDateChange={setCurrentDate}
        />
        
        <div className="flex-1 overflow-hidden">
          {view === 'day' && <DayView currentDate={currentDate} events={events} />}
          {view === 'week' && <WeekView currentDate={currentDate} events={events} />}
          {view === 'month' && <MonthView currentDate={currentDate} events={events} />}
        </div>
      </main>
    </>
  );
}

// 7. src/styles/globals.css
// このファイルにTailwind CSSの設定を追加します
/*
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  --foreground-rgb: 0, 0, 0;
  --background-rgb: 255, 255, 255;
}

* {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
}

html,
body {
  max-width: 100vw;
  overflow-hidden;
  color: rgb(var(--foreground-rgb));
  background: rgb(var(--background-rgb));
}
*/

// 8. tailwind.config.js
// 必要に応じてTailwind CSSの設定を追加します
/*
module.exports = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
*/

便利なもので、どのディレクトリに何を配置したらいいのかまで記載してくれます笑

2. 実際のコードに上記のコードを反映

詳細は省きますが、上記のコードをコンポーネント化するなりしてうまいこと利用すると以下のような感じのUIができあがります(詳細言えず申し訳ないです笑)
スクリーンショット 2025-03-04 19.07.58.png
スクリーンショット 2025-03-04 19.08.11.png
スクリーンショット 2025-03-04 19.08.22.png

最後に

正直、役にあまり立たない記事になってしまった感はありますが、皆様の参考になりますと幸いです笑

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?