ba-cafe

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit f355932d54278e7e29cfcf64bfdfa19da9d4579c
parent 70411bb6285f05ee497a1895df42a5aec7a41114
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date:   Tue, 27 Jan 2026 13:42:07 +0900

Merge pull request #30 from Sunny-JP/v4-sunny-dev

V4 sunny dev
Diffstat:
Mpackage-lock.json | 7+++++++
Mpackage.json | 1+
Msrc/app/globals.css | 125+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/components/HistoryCalendar.tsx | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/components/Settings.tsx | 29-----------------------------
5 files changed, 183 insertions(+), 124 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -14,6 +14,7 @@ "chart.js": "^4.5.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "html-to-image": "^1.11.13", "next": "16.0.10", "react": "19.2.3", "react-chartjs-2": "^5.3.1", @@ -6018,6 +6019,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", diff --git a/package.json b/package.json @@ -16,6 +16,7 @@ "chart.js": "^4.5.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "html-to-image": "^1.11.13", "next": "16.0.10", "react": "19.2.3", "react-chartjs-2": "^5.3.1", diff --git a/src/app/globals.css b/src/app/globals.css @@ -1,55 +1,53 @@ @import "tailwindcss"; :root { - --background: #f0f4f8; - --foreground: #000000; - --card: rgba(255, 255, 255, 0.85); - --card-foreground: #000000; - --primary: hsl(var(--tap-h) var(--tap-s) 42%); + --background: #f0f8ff; /* わずかに青みのある白 */ + --foreground: #1a2a3a; + --card: #ffffff; + --card-foreground: #1a2a3a; + --primary: hsl(var(--tap-h) var(--tap-s) 55%); --primary-foreground: #ffffff; - --secondary: #000000; - --secondary-foreground: #666666; - --muted: #cbd5e0; - --muted-foreground: #777777; - --nav-foreground: #555555; + --secondary: #2c3e50; + --secondary-foreground: #5a7d9a; + --muted: #d0e1f0; + --muted-foreground: #8ba8c2; + --nav-foreground: #607d8b; --tap-h: 200; --tap-s: 100%; - --tap-bg-0: #ededed; + --tap-bg-0: #ffffff; --tap-bg-1: hsl(var(--tap-h) var(--tap-s) 95%); --tap-bg-2: hsl(var(--tap-h) var(--tap-s) 93%); - --tap-bg-3: hsl(var(--tap-h) var(--tap-s) 88%); - --tap-bg-4: hsl(var(--tap-h) var(--tap-s) 83%); - --tap-bg-5: hsl(var(--tap-h) var(--tap-s) 75%); - --tap-bg-6: hsl(var(--tap-h) var(--tap-s) 70%); - --tap-bg-7: hsl(var(--tap-h) var(--tap-s) 60%); - --tap-bg-8: hsl(var(--tap-h) var(--tap-s) 50%); - --setting: #333333; - --setting-foreground: #000000; + --tap-bg-3: hsl(var(--tap-h) var(--tap-s) 89%); + --tap-bg-4: hsl(var(--tap-h) var(--tap-s) 86%); + --tap-bg-5: hsl(var(--tap-h) var(--tap-s) 79%); + --tap-bg-6: hsl(var(--tap-h) var(--tap-s) 74%); + --tap-bg-7: hsl(var(--tap-h) var(--tap-s) 66%); + --tap-bg-8: hsl(var(--tap-h) var(--tap-s) 59%); } .dark { --background: #000000; - --foreground: #ffffff; - --card: #151515; - --card-foreground: #ffffff; - --primary: hsl(var(--tap-h) var(--tap-s) 94%); - --primary-foreground: #333333; - --secondary: #ffffff; - --secondary-foreground: #aaaaaa; - --muted: #555555; - --muted-foreground: #aaaaaa; - --nav-foreground: #aaaaaa; - --tap-h: 200; - --tap-s: 75%; - --tap-bg-0: #151515; - --tap-bg-8: hsl(var(--tap-h) var(--tap-s) 50%); - --tap-bg-7: hsl(var(--tap-h) var(--tap-s) 42%); - --tap-bg-6: hsl(var(--tap-h) var(--tap-s) 33%); - --tap-bg-5: hsl(var(--tap-h) var(--tap-s) 26%); - --tap-bg-4: hsl(var(--tap-h) var(--tap-s) 18%); - --tap-bg-3: hsl(var(--tap-h) var(--tap-s) 14%); - --tap-bg-2: hsl(var(--tap-h) var(--tap-s) 10%); - --tap-bg-1: hsl(var(--tap-h) var(--tap-s) 7%); + --foreground: #f5f0f7; + --card: #151b2b; + --card-foreground: #f5f0f7; + --primary: hsl(var(--tap-h) var(--tap-s) 75%); + --primary-foreground: #1c1425; + --secondary: #d7d5e8; + --secondary-foreground: #a0abc2; + --muted: #4b5068; + --muted-foreground: #7d8dad; + --nav-foreground: #96a4bd; + --tap-h: 225; + --tap-s: 40%; + --tap-bg-0: #0f0e1a; + --tap-bg-8: hsl(var(--tap-h) var(--tap-s) 56%); + --tap-bg-7: hsl(var(--tap-h) var(--tap-s) 50%); + --tap-bg-6: hsl(var(--tap-h) var(--tap-s) 42%); + --tap-bg-5: hsl(var(--tap-h) var(--tap-s) 35%); + --tap-bg-4: hsl(var(--tap-h) var(--tap-s) 28%); + --tap-bg-3: hsl(var(--tap-h) var(--tap-s) 23%); + --tap-bg-2: hsl(var(--tap-h) var(--tap-s) 16%); + --tap-bg-1: hsl(var(--tap-h) var(--tap-s) 12%); } html { @@ -125,7 +123,11 @@ body { .btn-nav:hover { color: var(--primary); } -.btn-nav.active { +.dark .btn-nav:hover { + color: #f5f0f7; +} +.btn-nav.active, +.btn-nav:hover.active { background-color: var(--primary); color: var(--primary-foreground); } @@ -270,16 +272,12 @@ body { font-weight: 700; } .cal-days { - font-size: 40cqw; - font-weight: 700; - -webkit-text-stroke: 1px var(--primary-foreground); - paint-order: stroke; + font-size: 38cqw; + font-weight: 400; } .cal-tap { - font-size: 50cqw; + font-size: 52cqw; font-weight: 700; - -webkit-text-stroke: 2px var(--primary-foreground); - paint-order: stroke; } .cal-nav { flex: 1 1 0%; @@ -293,49 +291,50 @@ body { background-color: transparent; transition: all 0.2s ease-in-out; } -.cal-nav:hover { +.cal-save-nav { + font-size: 2.5cqw; + color: var(--secondary-foreground); + background-color: transparent; + transition: all 0.2s ease-in-out; +} +.cal-nav:hover, +.cal-save-nav:hover { color: var(--primary); } +.dark .cal-nav:hover, +.dark .cal-save-nav:hover { + color: #f5f0f7; +} .cal-cell { container-type: inline-size; - background-color: var(--card); + border: none; +} +.bg-tap-0 { + background-color: var(--tap-bg-0); } - .bg-tap-1 { background-color: var(--tap-bg-1); - border: none; } .bg-tap-2 { background-color: var(--tap-bg-2); - border: none; } .bg-tap-3 { background-color: var(--tap-bg-3); - border: none; } .bg-tap-4 { background-color: var(--tap-bg-4); - border: none; } .bg-tap-5 { background-color: var(--tap-bg-5); - border: none; } .bg-tap-6 { background-color: var(--tap-bg-6); - border: none; } .bg-tap-7 { background-color: var(--tap-bg-7); - border: none; } .bg-tap-8 { background-color: var(--tap-bg-8); - border: none; -} -.bg-tap-9 { - background-color: var(--tap-bg-9); - border: none; } input[type="range"] { diff --git a/src/components/HistoryCalendar.tsx b/src/components/HistoryCalendar.tsx @@ -1,7 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { formatInTimeZone, toZonedTime } from 'date-fns-tz'; +import { toPng } from 'html-to-image'; const JST_TZ = 'Asia/Tokyo'; +const SITE_NAME = "My Tap History by Café Timer"; +const SITE_URL = "https://cafetimer.rabbit1.cc"; interface HistoryCalendarProps { tapHistory: number[]; @@ -9,6 +12,8 @@ interface HistoryCalendarProps { const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { const [currentDate, setCurrentDate] = useState(() => toZonedTime(new Date(), JST_TZ)); + const [isExporting, setIsExporting] = useState(false); + const exportRef = useRef<HTMLDivElement>(null); const getLogicalDateString = (timestamp: number): string => { const adjustedTime = timestamp - 4 * 60 * 60 * 1000; @@ -17,9 +22,7 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { const tapsByDate = (tapHistory || []).reduce((acc, tap) => { const dateString = getLogicalDateString(tap); - if (!acc[dateString]) { - acc[dateString] = []; - } + if (!acc[dateString]) acc[dateString] = []; acc[dateString].push(tap); return acc; }, {} as Record<string, number[]>); @@ -30,35 +33,43 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { const startDay = (firstDayIndex + 6) % 7; const daysInMonth = new Date(year, month + 1, 0).getDate(); - const getTapBgClass = (tapCount: number) => { - if (tapCount === 0) return 'bg-tap-0'; - if (tapCount === 1) return 'bg-tap-1'; - if (tapCount === 2) return 'bg-tap-2'; - if (tapCount === 3) return 'bg-tap-3'; - if (tapCount === 4) return 'bg-tap-4'; - if (tapCount === 5) return 'bg-tap-5'; - if (tapCount === 6) return 'bg-tap-6'; - if (tapCount === 7) return 'bg-tap-7'; - return 'bg-tap-8'; + const handleExportImage = async () => { + if (!exportRef.current) return; + setIsExporting(true); + await new Promise((resolve) => setTimeout(resolve, 200)); + + try { + const dataUrl = await toPng(exportRef.current, { + canvasWidth: 1440, + canvasHeight: 1440, + width: 1440, + height: 1440, + style: { transform: 'none' }, + cacheBust: true, + }); + + const link = document.createElement('a'); + link.download = `History-${year}-${month + 1}.png`; + link.href = dataUrl; + link.click(); + } catch (err) { + console.error('Export failed', err); + } finally { + setIsExporting(false); + } }; const calendarDays = []; for (let i = 0; i < startDay; i++) { - calendarDays.push(<div key={`empty-${i}`} className="p-2 aspect-square rounded-lg"></div>); + calendarDays.push(<div key={`empty-${i}`} className="p-2 aspect-square"></div>); } - for (let day = 1; day <= daysInMonth; day++) { - const monthStr = String(month + 1).padStart(2, '0'); - const dayStr = String(day).padStart(2, '0'); - const dateKey = `${year}-${monthStr}-${dayStr}`; - + const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const taps = tapsByDate[dateKey] || []; - const bgClass = getTapBgClass(taps.length); - calendarDays.push( - <div key={day} className={`relative p-2 aspect-square ${bgClass} cal-cell`}> - <div className="absolute top-1 left-2 cal-days">{day}</div> - <div className="absolute bottom-1 right-2 cal-tap"> + <div key={day} className={`relative p-2 aspect-square rounded-sm cal-cell ${isExporting ? 'export-cell' : ''} ${taps.length === 0 ? 'bg-tap-0' : `bg-tap-${Math.min(taps.length, 8)}`}`}> + <div className={`absolute top-1 left-2 cal-days ${isExporting ? 'export-text-days' : ''}`}>{day}</div> + <div className={`absolute bottom-1 right-2 cal-tap ${isExporting ? 'export-text-taps' : ''}`}> {taps.length > 0 ? taps.length : ''} </div> </div> @@ -73,17 +84,87 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { return ( <div className="cal-container"> - <div className="flex justify-between items-center mb-4"> + <div className="flex justify-between items-center mb-6"> <button onClick={() => changeMonth(-1)} className="cal-nav">Prev</button> - <div className="cal-info">{year}. {month + 1}</div> + <div className="flex flex-col items-center"> + <div className="cal-info">{year}. {month + 1}</div> + <button onClick={handleExportImage} className="cal-save-nav uppercase tracking-widest"> + Save Image + </button> + </div> <button onClick={() => changeMonth(1)} className="cal-nav">Next</button> </div> - <div className="grid grid-cols-7 gap-1.5"> - {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( - <div key={d} className="text-center cal-week">{d}</div> - ))} - {calendarDays} + + <div + ref={exportRef} + className={`bg-(--background) ${isExporting ? 'export-container' : 'w-full rounded-2xl'}`} + > + {isExporting && ( + <div className="export-header">{year} / {String(month + 1).padStart(2, '0')}</div> + )} + + <div className={`grid grid-cols-7 ${isExporting ? 'export-grid' : 'gap-2'}`}> + {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( + <div key={d} className={`text-center font-bold cal-week ${isExporting ? 'export-week' : 'text-xl pb-4'}`}>{d}</div> + ))} + {calendarDays} + </div> + + {isExporting && ( + <div className="export-footer"> + <div className="export-site-name">{SITE_NAME}</div> + <div className="export-site-url">{SITE_URL}</div> + </div> + )} </div> + + <style jsx>{` + .export-container { + position: fixed; + top: 0; + left: 0; + width: 1440px; + height: 1440px; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 80px 100px; + z-index: -100; + } + .export-header { + font-size: 100px !important; + font-weight: 800; + text-align: center; + margin-bottom: 20px; + } + .export-grid { + gap: 20px !important; + } + .export-week { + font-size: 32px !important; + opacity: 0.6; + } + :global(.export-text-days) { + font-size: 50px !important; + line-height: 1 !important; + left: 15px !important; + top: 15px !important; + } + :global(.export-text-taps) { + font-size: 70px !important; + line-height: 1 !important; + font-weight: 700 !important; + right: 20px !important; + bottom: 20px !important; + } + .export-footer { + text-align: center; + opacity: 0.5; + margin-top: 30px; + } + .export-site-name { font-size: 38px; font-weight: 700; } + .export-site-url { font-size: 24px; } + `}</style> </div> ); }; diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx @@ -29,23 +29,6 @@ const BellIcon = ({ className = 'h-5 w-5 mr-2' }: { className?: string }) => ( const Settings = () => { const { isLoggedIn, logout, avatarUrl, displayName } = useAuth(); const [isDeleting, setIsDeleting] = useState(false); - const [hue, setHue] = useState(200); - - useEffect(() => { - const savedHue = localStorage.getItem('theme-hue'); - if (savedHue) { - const h = parseInt(savedHue); - setHue(h); - document.documentElement.style.setProperty('--tap-h', h.toString()); - } - }, []); - - const handleHueChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const val = parseInt(e.target.value); - setHue(val); - document.documentElement.style.setProperty('--tap-h', val.toString()); - localStorage.setItem('theme-hue', val.toString()); - }; const menuItems = [ { label: 'About', path: '/about' }, @@ -137,18 +120,6 @@ const Settings = () => { ))} </ul> - <div className="p-2 rounded-xl"> - <div className="flex justify-between items-center mb-2"> - <label className="text-xs font-mono opacity-70">Theme Color</label> - <span className="text-xs font-mono opacity-60">Hue: {hue}</span> - </div> - <input - type="range" min="0" max="360" value={hue} onChange={handleHueChange} - className="w-full h-2 rounded-lg appearance-none cursor-pointer" - style={{ background: `linear-gradient(to right, hsl(0, 80%, 50%), hsl(60, 80%, 50%), hsl(120, 80%, 50%), hsl(180, 80%, 50%), hsl(240, 80%, 50%), hsl(300, 80%, 50%), hsl(360, 80%, 50%))` }} - /> - </div> - <div className="mt-8 border-t pt-4"> {isLoggedIn && ( <>