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:
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 && (
<>