Add Icon Options

This commit is contained in:
2025-10-17 16:30:48 +02:00
parent 28cedf9421
commit 217ec8b15a
4 changed files with 132 additions and 42 deletions

21
package-lock.json generated
View File

@@ -93,7 +93,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -691,7 +690,6 @@
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1536,7 +1534,6 @@
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-module-imports": "^7.27.1",
@@ -4135,7 +4132,6 @@
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4160,7 +4156,6 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4172,7 +4167,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4264,7 +4258,6 @@
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -4504,7 +4497,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5049,7 +5041,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -5834,7 +5825,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -7384,7 +7374,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -8127,7 +8116,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8285,7 +8273,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8298,7 +8285,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8502,8 +8488,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -9354,7 +9339,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -9548,7 +9532,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9932,7 +9915,6 @@
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -10026,7 +10008,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -6,6 +6,14 @@ import { Button } from './ui/button';
import MiniGrid from './MiniGrid';
import AnimatedCounter from './AnimatedCounter';
// Helper to get streak icon from localStorage or fallback
function getStreakIcon() {
const icon = typeof window !== 'undefined' ? localStorage.getItem('streakIcon') : null;
if (!icon || icon === 'flame') return <Flame className="w-4 h-4 text-orange-500" />;
return <span className="w-4 h-4 text-lg align-text-bottom" role="img" aria-label="Streak Icon">{icon}</span>;
}
const HabitCard = ({ habit, onUpdate }) => {
const navigate = useNavigate();
@@ -27,11 +35,11 @@ const HabitCard = ({ habit, onUpdate }) => {
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Flame className="w-4 h-4 text-orange-500" />
<span><AnimatedCounter value={habit.currentStreak || 0} duration={800} /> day streak</span>
{getStreakIcon()}
<span><AnimatedCounter value={habit.currentStreak || 0} duration={800} /> day streak</span>
</div>
<span></span>
<span>Personal Record: <AnimatedCounter value={habit.longestStreak || 0} duration={800} /> days</span>
<span>Personal Record: <AnimatedCounter value={habit.longestStreak || 0} duration={800} /> days</span>
</div>
</div>
<Button

View File

@@ -13,6 +13,30 @@ function lightenColor(hex, percent) {
return `rgb(${r},${g},${b})`;
}
import { Flame } from 'lucide-react';
// Helpers to get custom icons from localStorage or fallback
function getStreakIcon() {
if (typeof window === 'undefined') return (
<span className="flex items-center justify-center w-full h-full">
<Flame className="w-4 h-4 drop-shadow-lg" />
</span>
);
const icon = localStorage.getItem('streakIcon');
if (!icon || icon === 'flame') return (
<span className="flex items-center justify-center w-full h-full">
<Flame className="w-4 h-4 drop-shadow-lg" />
</span>
);
return (
<span className="flex items-center justify-center w-full h-full">
<span className="text-lg" role="img" aria-label="Streak Icon">{icon}</span>
</span>
);
}
function getFreezeIcon() {
if (typeof window === 'undefined') return '❄️';
const icon = localStorage.getItem('freezeIcon');
return icon || '❄️';
}
import { motion } from 'framer-motion';
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
import { getFrozenDays } from '../lib/utils-habit';
@@ -122,7 +146,7 @@ const MiniGrid = ({ habit, onUpdate }) => {
}}
transition={{ duration: 0.7, ease: 'easeInOut' }}
>
{getFreezeIcon()}
</motion.span>
)}
{/* Flame icon for full streak days */}
@@ -147,6 +171,7 @@ const MiniGrid = ({ habit, onUpdate }) => {
whileTap={{ scale: 1.2, rotate: 0 }}
>
<motion.div
className="flex items-center justify-center w-full h-full"
animate={{ rotate: [0, 12, -12, 0] }}
transition={{
repeat: Infinity,
@@ -154,12 +179,8 @@ const MiniGrid = ({ habit, onUpdate }) => {
duration: 2,
ease: 'easeInOut',
}}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}
>
<Flame
className="w-3 h-3 sm:w-4 sm:h-4 md:w-5 md:h-5 drop-shadow-lg"
style={{ color: lightenColor(habit.color, 0.4), filter: 'brightness(1.3) drop-shadow(0 0 6px white)' }}
/>
{getStreakIcon()}
</motion.div>
</motion.span>
)}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2, Plus, Trash, GitBranch } from 'lucide-react';
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2, Plus, Trash, GitBranch, Flame } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Switch } from '../components/ui/switch';
import { Label } from '../components/ui/label';
@@ -9,7 +9,42 @@ import { useToast } from '../components/ui/use-toast';
import { exportData, importData, clearAllData } from '../lib/storage';
import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
const DEFAULT_STREAK_ICON = 'flame';
const DEFAULT_FREEZE_ICON = '❄️';
const ICON_OPTIONS = [
{ label: 'Flame', value: 'flame', icon: <Flame className="inline w-5 h-5 text-orange-500 align-text-bottom" /> },
{ label: 'Fire (emoji)', value: '🔥', icon: <span role="img" aria-label="Fire" className="inline text-lg align-text-bottom">🔥</span> },
{ label: 'Star', value: '⭐', icon: <span role="img" aria-label="Star" className="inline text-lg align-text-bottom"></span> },
{ label: 'Trophy', value: '🏆', icon: <span role="img" aria-label="Trophy" className="inline text-lg align-text-bottom">🏆</span> },
{ label: 'Rocket', value: '🚀', icon: <span role="img" aria-label="Rocket" className="inline text-lg align-text-bottom">🚀</span> },
{ label: 'Rose', value: '🌹', icon: <span role="img" aria-label="Rose" className="inline text-lg align-text-bottom">🌹</span> },
];
const FREEZE_OPTIONS = [
{ label: 'Snowflake', value: '❄️', icon: <span role="img" aria-label="Snowflake" className="inline text-lg align-text-bottom"></span> },
{ label: 'Ice', value: '🧊', icon: <span role="img" aria-label="Ice" className="inline text-lg align-text-bottom">🧊</span> },
{ label: 'Snowman', value: '☃️', icon: <span role="img" aria-label="Snowman" className="inline text-lg align-text-bottom"></span> },
{ label: 'Cloud', value: '☁️', icon: <span role="img" aria-label="Cloud" className="inline text-lg align-text-bottom"></span> },
{ label: 'Withered Flower', value: '🥀', icon: <span role="img" aria-label="Withered Flower" className="inline text-lg align-text-bottom">🥀</span> },
];
const SettingsPage = () => {
// Appearance customization state
const [streakIcon, setStreakIcon] = useState(() => localStorage.getItem('streakIcon') || DEFAULT_STREAK_ICON);
const [freezeIcon, setFreezeIcon] = useState(() => localStorage.getItem('freezeIcon') || DEFAULT_FREEZE_ICON);
// Save icon selections to localStorage
useEffect(() => {
localStorage.setItem('streakIcon', streakIcon);
}, [streakIcon]);
useEffect(() => {
localStorage.setItem('freezeIcon', freezeIcon);
}, [freezeIcon]);
// Render icon for preview
const renderStreakIcon = (icon) => {
if (icon === 'flame') return <Flame className="inline w-5 h-5 text-orange-500 align-text-bottom" />;
return <span className="inline text-lg align-text-bottom">{icon}</span>;
};
const renderFreezeIcon = (icon) => <span className="inline text-lg align-text-bottom">{icon}</span>;
const navigate = useNavigate();
const { toast } = useToast();
const [darkMode, setDarkMode] = useState(() => {
@@ -150,6 +185,7 @@ const SettingsPage = () => {
<div className="space-y-4">
{/* Appearance */}
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -158,19 +194,63 @@ const SettingsPage = () => {
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
>
<h2 className="text-lg font-semibold mb-4">Appearance</h2>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{darkMode ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
<div>
<Label htmlFor="dark-mode" className="text-base">Dark Mode</Label>
<p className="text-sm text-muted-foreground">Toggle dark theme</p>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{darkMode ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
<div>
<Label htmlFor="dark-mode" className="text-base">Dark Mode</Label>
<p className="text-sm text-muted-foreground">Toggle dark theme</p>
</div>
</div>
<Switch
id="dark-mode"
checked={darkMode}
onCheckedChange={toggleDarkMode}
/>
</div>
{/* Streak Icon Picker */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{renderStreakIcon(streakIcon)}
<div>
<Label htmlFor="streak-icon" className="text-base">Streak Icon</Label>
<p className="text-sm text-muted-foreground">Choose your streak icon</p>
</div>
</div>
<select
id="streak-icon"
className="border rounded-md p-2 bg-white dark:bg-slate-900 text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
value={streakIcon}
onChange={e => setStreakIcon(e.target.value)}
>
{ICON_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Freeze Icon Picker */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{renderFreezeIcon(freezeIcon)}
<div>
<Label htmlFor="freeze-icon" className="text-base">Freeze Icon</Label>
<p className="text-sm text-muted-foreground">Choose your freeze icon</p>
</div>
</div>
<select
id="freeze-icon"
className="border rounded-md p-2 bg-white dark:bg-slate-900 text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
value={freezeIcon}
onChange={e => setFreezeIcon(e.target.value)}
>
{FREEZE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<Switch
id="dark-mode"
checked={darkMode}
onCheckedChange={toggleDarkMode}
/>
</div>
</motion.div>
@@ -254,7 +334,7 @@ const SettingsPage = () => {
<div className="grid sm:grid-cols-4 gap-2 mb-3">
<div>
<Label className="text-xs">Provider</Label>
<select className="w-full bg-transparent border rounded-md p-2" value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
<select className="w-full border rounded-md p-2 bg-white dark:bg-slate-900 text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors" value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="gitea">Gitea</option>