From 0fe6bd7ea69fd455508e4a50905d936bec80ecbc Mon Sep 17 00:00:00 2001 From: Chelsea Date: Wed, 29 Apr 2026 17:41:10 -0500 Subject: [PATCH] first commit --- .env | 2 + Dockerfile | 2 + api/main.py | 13 +- api/routes/__init__.py | 30 ++ bot/bot.py | 104 ++++-- bot/commands/__init__.py | 26 ++ bot/commands/music.py | 67 ++++ bot/prefix_command_registry.py | 40 +++ bot/voice_manager.py | 295 +++++++++++++++++ plugins/__init__.py | 47 +++ plugins/example_plugin.py | 44 +++ requirements.txt | 3 + static/style.css | 559 ++++++++++++++++++++++++++++++++ templates/New Text Document.txt | 0 templates/index.html | 479 +++++++++++++++++++++++++++ 15 files changed, 1678 insertions(+), 33 deletions(-) create mode 100644 api/routes/__init__.py create mode 100644 bot/commands/__init__.py create mode 100644 bot/commands/music.py create mode 100644 bot/prefix_command_registry.py create mode 100644 bot/voice_manager.py create mode 100644 plugins/__init__.py create mode 100644 plugins/example_plugin.py create mode 100644 static/style.css create mode 100644 templates/New Text Document.txt create mode 100644 templates/index.html diff --git a/.env b/.env index 99139f8..d725f0c 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ DB_PASS=y8Khu7pJQZq6ywFDIJiqpx4zYmclHGHw +COMMAND_CHANNEL_ID=123456789012345678 +ADMIN_USER_IDS=1111222233334444,5555666677778888 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 444d8b2..dde9ca1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM python:3.11-slim WORKDIR /app +RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/api/main.py b/api/main.py index 501e2f6..f3555b0 100644 --- a/api/main.py +++ b/api/main.py @@ -131,7 +131,14 @@ def health_check(): if __name__ == "__main__": - for module in ROUTE_MODULES: - if hasattr(module, "register"): - module.register(app) + from api.routes import load_routes + load_routes(app) + + # Optional: load general plugins + try: + from plugins import load_plugins + load_plugins(app) + except ImportError: + pass # plugins folder not present + app.run(host="0.0.0.0", port=5000) diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..2cd1ef0 --- /dev/null +++ b/api/routes/__init__.py @@ -0,0 +1,30 @@ +import os +import importlib.util +import logging + +logger = logging.getLogger(__name__) + +def load_routes(app): + """ + Automatically discover and load route modules from api/routes/. + + Each module should have a register(app) function. + """ + routes_dir = os.path.dirname(__file__) + for filename in os.listdir(routes_dir): + if filename.endswith('.py') and filename != '__init__.py': + module_name = filename[:-3] # remove .py + module_path = os.path.join(routes_dir, filename) + + try: + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if hasattr(module, 'register'): + module.register(app) + logger.info(f"Loaded route module: {module_name}") + else: + logger.warning(f"Route module {module_name} has no register() function") + except Exception as e: + logger.error(f"Failed to load route module {module_name}: {e}") diff --git a/bot/bot.py b/bot/bot.py index c7a7502..497c6fc 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -19,6 +19,7 @@ import bcrypt import pickle from bot.command_registry import get_handler, list_registered +import bot.prefix_command_registry as prefix_registry import ai.parser as ai_parser DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") @@ -32,6 +33,7 @@ CACHE_FILE = "/app/user_cache.pkl" intents = discord.Intents.default() intents.message_content = True +intents.voice_states = True client = discord.Client(intents=intents) @@ -181,7 +183,7 @@ async def handleLoginStep(message): async def sendHelpMessage(message): registered = list_registered() - help_msg = f"**Available Modules:**\n{chr(10).join(f'- {m}' for m in registered) if registered else '- No modules registered'}\n\nJust talk naturally and I'll help you out!" + help_msg = f"**Available Modules:**\n{chr(10).join(f'- {m}' for m in registered) if registered else '- No modules registered'}\n\nJust talk naturally and I'll try to help you out!" await message.channel.send(help_msg) @@ -190,41 +192,71 @@ async def routeCommand(message): session = user_sessions[discord_id] user_input = message.content.lower() - if "help" in user_input or "what can i say" in user_input: - await sendHelpMessage(message) - return + # if "help" in user_input in user_input: + # await sendHelpMessage(message) + # return + if isinstance(message.channel, discord.DMChannel): + ADMIN_IDS = [ + int(uid.strip()) + for uid in os.getenv("ADMIN_USER_IDS", "").split(",") + if uid.strip() + ] + if discord_id in ADMIN_IDS: + async with message.channel.typing(): + history = message_history.get(discord_id, []) + parsed = ai_parser.parse( + message.content, "command_parser", history=history + ) - async with message.channel.typing(): - history = message_history.get(discord_id, []) - parsed = ai_parser.parse(message.content, "command_parser", history=history) + if discord_id not in message_history: + message_history[discord_id] = [] + message_history[discord_id].append((message.content, parsed)) + message_history[discord_id] = message_history[discord_id][-5:] - if discord_id not in message_history: - message_history[discord_id] = [] - message_history[discord_id].append((message.content, parsed)) - message_history[discord_id] = message_history[discord_id][-5:] + if "needs_clarification" in parsed: + await message.channel.send( + f"I'm not quite sure what you mean. {parsed['needs_clarification']}" + ) + return - if "needs_clarification" in parsed: - await message.channel.send( - f"I'm not quite sure what you mean. {parsed['needs_clarification']}" - ) - return + if "error" in parsed: + await message.channel.send( + f"I had trouble understanding that: {parsed['error']}" + ) + return - if "error" in parsed: - await message.channel.send( - f"I had trouble understanding that: {parsed['error']}" - ) - return + interaction_type = parsed.get("interaction_type") + handler = get_handler(interaction_type) - interaction_type = parsed.get("interaction_type") - handler = get_handler(interaction_type) + if handler: + await handler(message, session, parsed) + else: + registered = ", ".join(list_registered()) or "none" + await message.channel.send( + f"Unknown command type '{interaction_type}'. Registered modules: {registered}" + ) + else: + await message.channel.send("You don't have permission to use this bot.") - if handler: - await handler(message, session, parsed) - else: - registered = ", ".join(list_registered()) or "none" - await message.channel.send( - f"Unknown command type '{interaction_type}'. Registered modules: {registered}" - ) + elif hasattr(message.channel, "guild") and message.channel.guild is not None: + # we are in a guild channel, now we need to determine which + if message.channel.id == int(os.getenv("COMMAND_CHANNEL_ID", 0)): + content = message.content.strip() + if not content.startswith("!"): + return + + parts = content[1:].split() + if not parts: + return + + command_name = parts[0].lower() + args = parts[1:] + + handler = prefix_registry.get_prefix_handler(command_name) + if handler: + await handler(message, args) + else: + await message.channel.send(f"Unknown command '{command_name}'.") @client.event @@ -267,4 +299,16 @@ async def beforeBackgroundLoop(): if __name__ == "__main__": + from bot.commands import load_commands + + load_commands() + + # Optional: load general plugins + try: + from plugins import load_plugins + + load_plugins() + except ImportError: + pass # plugins folder not present + client.run(DISCORD_BOT_TOKEN) diff --git a/bot/commands/__init__.py b/bot/commands/__init__.py new file mode 100644 index 0000000..66d8e3b --- /dev/null +++ b/bot/commands/__init__.py @@ -0,0 +1,26 @@ +import os +import importlib.util +import logging + +logger = logging.getLogger(__name__) + +def load_commands(): + """ + Automatically discover and load command modules from bot/commands/. + + Each module should call register_module() during import. + """ + commands_dir = os.path.dirname(__file__) + for filename in os.listdir(commands_dir): + if filename.endswith('.py') and filename != '__init__.py': + module_name = filename[:-3] # remove .py + module_path = os.path.join(commands_dir, filename) + + try: + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + logger.info(f"Loaded command module: {module_name}") + except Exception as e: + logger.error(f"Failed to load command module {module_name}: {e}") diff --git a/bot/commands/music.py b/bot/commands/music.py new file mode 100644 index 0000000..bd3f7c7 --- /dev/null +++ b/bot/commands/music.py @@ -0,0 +1,67 @@ +""" +music.py - Prefix commands for music playback. + +Register: + !play - Download and play a song (adds to queue if already playing) + !skip - Skip current song, play next + !stop - Stop playback, clear queue, leave voice + !queue - Show current queue with details + !nowplaying - Show currently playing track with remaining time +""" + +from bot.prefix_command_registry import register_prefix_command +from bot import voice_manager + + +async def handle_play(message, args): + query = " ".join(args) + if not query: + await message.channel.send("Usage: `!play `") + return + + await voice_manager.resolve_and_enqueue(message, query) + + +async def handle_skip(message, args): + guild_id = message.guild.id + vc = voice_manager.connections.get(guild_id) + if not vc: + await message.channel.send("Not playing anything.") + return + + if not vc.is_playing(): + await message.channel.send("Not playing anything.") + return + + voice_manager.skip(guild_id) + await message.channel.send("Skipped.") + + +async def handle_stop(message, args): + guild_id = message.guild.id + vc = voice_manager.connections.get(guild_id) + if not vc: + await message.channel.send("Not playing anything.") + return + + voice_manager.stop(guild_id) + await message.channel.send("Stopped and left voice channel.") + + +async def handle_queue(message, args): + guild_id = message.guild.id + output = voice_manager.get_queue(guild_id) + await message.channel.send(output) + + +async def handle_nowplaying(message, args): + guild_id = message.guild.id + output = voice_manager.get_now_playing(guild_id) + await message.channel.send(output) + + +register_prefix_command("play", handle_play) +register_prefix_command("skip", handle_skip) +register_prefix_command("stop", handle_stop) +register_prefix_command("queue", handle_queue) +register_prefix_command("nowplaying", handle_nowplaying) diff --git a/bot/prefix_command_registry.py b/bot/prefix_command_registry.py new file mode 100644 index 0000000..81c08cf --- /dev/null +++ b/bot/prefix_command_registry.py @@ -0,0 +1,40 @@ +""" +prefix_command_registry.py - Module registration for prefix commands + +Same pattern as command_registry.py, but for !command-style handlers +in the command text channel. No AI, no session, no auth. + +Handler signature: async def handler(message, args) -> None + message: discord.Message object + args: list of strings (everything after the command name) +""" + +PREFIX_COMMANDS = {} + + +def register_prefix_command(name, handler): + """ + Register a handler for a prefix command. + + Args: + name: String key (e.g., 'play', 'skip', 'queue') + handler: Async function(message, args) -> None + + Example: + async def handle_play(message, args): + query = " ".join(args) + # ... search YouTube, join voice, play ... + + register_prefix_command('play', handle_play) + """ + PREFIX_COMMANDS[name] = handler + + +def get_prefix_handler(name): + """Get the registered handler for a prefix command name.""" + return PREFIX_COMMANDS.get(name) + + +def list_prefix_commands(): + """List all registered prefix command names.""" + return list(PREFIX_COMMANDS.keys()) \ No newline at end of file diff --git a/bot/voice_manager.py b/bot/voice_manager.py new file mode 100644 index 0000000..6ba9fb5 --- /dev/null +++ b/bot/voice_manager.py @@ -0,0 +1,295 @@ +""" +voice_manager.py - Voice connection, queue, and playback for Discord music. + +Uses yt-dlp to download bestaudio to disk, then plays via FFmpegPCMAudio. +Files persist indefinitely in AUDIO_DIR (acts as a cache). +""" + +import os +import asyncio +import time +import yt_dlp +import discord + +AUDIO_DIR = "/app/audio_cache" +os.makedirs(AUDIO_DIR, exist_ok=True) + +connections = {} +queues = {} +playing = {} +download_locks = {} +retries = {} +command_channels = {} + + +def _get_download_lock(guild_id): + if guild_id not in download_locks: + download_locks[guild_id] = asyncio.Lock() + return download_locks[guild_id] + + +def _format_duration(seconds): + if not seconds: + return "??:??" + m, s = divmod(int(seconds), 60) + return f"{m}:{s:02d}" + + +def _get_cached_path(video_id): + for ext in [".webm", ".opus", ".m4a", ".mp3", ".ogg", ".flac"]: + path = os.path.join(AUDIO_DIR, f"{video_id}{ext}") + if os.path.exists(path): + return path + return None + + +def _download_sync(query): + ydl_opts = { + "format": "bestaudio", + "outtmpl": os.path.join(AUDIO_DIR, "%(id)s.%(ext)s"), + "no-playlist": True, + "quiet": True, + "no_warnings": True, + "socket_timeout": 30, + } + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(query, download=False) + video_id = info.get("id") + title = info.get("title", "Unknown") + duration = info.get("duration") + artist = info.get("artist") or info.get("creator") or info.get("channel", "") + + cached = _get_cached_path(video_id) + if cached: + return cached, title, duration, artist + + ydl.download([query]) + + filepath = _get_cached_path(video_id) + if not filepath: + for f in os.listdir(AUDIO_DIR): + if f.startswith(video_id): + filepath = os.path.join(AUDIO_DIR, f) + break + + if not filepath: + raise RuntimeError(f"Downloaded file not found for {video_id}") + + return filepath, title, duration, artist + + +async def join_voice(message): + guild_id = message.guild.id + + if guild_id in connections: + return connections[guild_id] + + voice_state = message.author.voice + if not voice_state: + return None + + channel = voice_state.channel + try: + vc = await channel.connect() + connections[guild_id] = vc + return vc + except Exception as e: + print(f"Error joining voice channel: {e}") + return None + + +async def resolve_and_enqueue(message, query): + guild_id = message.guild.id + command_channels[guild_id] = message.channel + + if guild_id not in queues: + queues[guild_id] = [] + + vc = connections.get(guild_id) + if not vc: + vc = await join_voice(message) + if not vc: + await message.channel.send("You need to be in a voice channel first.") + return None + + lock = _get_download_lock(guild_id) + + announce_msg = await message.channel.send("Preparing...") + + def do_download(): + return _download_sync(query) + + try: + async with lock: + position = len(queues[guild_id]) + 1 + filepath, title, duration, artist = await asyncio.to_thread(do_download) + + if guild_id not in retries: + retries[guild_id] = {} + retries[guild_id][filepath] = 0 + + queues[guild_id].append( + { + "filepath": filepath, + "title": title, + "duration": duration, + "artist": artist, + } + ) + + if guild_id not in playing or not playing[guild_id]: + await announce_msg.delete() + await play_next(guild_id) + else: + await announce_msg.edit( + content=f'Added "{title}" to queue at position #{position}.' + ) + + return queues[guild_id][-1] + + except Exception as e: + print(f"Download error for '{query}': {e}") + await message.channel.send(f'Couldn\'t download "{query}".') + return None + + +# UNUSED: kept for reference — play_next uses its own inline _after closure +# def _after_callback(guild_id): +# playing.pop(guild_id, None) +# +# loop = asyncio.get_event_loop() +# loop.call_soon_threadsafe(lambda: asyncio.ensure_future(_play_next_async(guild_id))) +# +# +# async def _play_next_async(guild_id): +# await play_next(guild_id) + + +async def play_next(guild_id): + vc = connections.get(guild_id) + if not vc: + return + + if not vc.is_connected(): + connections.pop(guild_id, None) + return + + if vc.is_playing(): + return + + queue = queues.get(guild_id, []) + if not queue: + return + + item = queue.pop(0) + filepath = item["filepath"] + title = item["title"] + duration = item["duration"] + artist = item["artist"] + + channel = command_channels.get(guild_id) + + def _after(error): + if error: + print(f"Playback error: {error}") + playing.pop(guild_id, None) + loop = asyncio.get_event_loop() + loop.call_soon_threadsafe(lambda: asyncio.ensure_future(play_next(guild_id))) + + try: + source = discord.FFmpegPCMAudio(filepath) + vc.play(source, after=_after) + playing[guild_id] = { + "started_at": time.time(), + "duration": duration, + "title": title, + "artist": artist, + } + + display = title + if artist: + display = f"{title} ({artist})" + + if channel: + await channel.send(f"Now playing: {display}") + + except Exception as e: + print(f"Playback error for {filepath}: {e}") + retry_count = retries.get(guild_id, {}).get(filepath, 0) + if retry_count < 1: + if guild_id not in retries: + retries[guild_id] = {} + retries[guild_id][filepath] = retry_count + 1 + queue.insert(0, item) + if channel: + await channel.send(f"Playback failed, retrying: {title}") + await play_next(guild_id) + else: + if guild_id in retries: + retries[guild_id].pop(filepath, None) + if channel: + await channel.send(f'Couldn\'t play "{title}", skipping.') + await play_next(guild_id) + + +def skip(guild_id): + vc = connections.get(guild_id) + if vc and vc.is_playing(): + vc.stop() + + +def stop(guild_id): + vc = connections.pop(guild_id, None) + if vc: + vc.stop() + asyncio.ensure_future(vc.disconnect()) + queues.pop(guild_id, None) + playing.pop(guild_id, None) + retries.pop(guild_id, None) + + +def get_queue(guild_id): + queue = queues.get(guild_id, []) + current = playing.get(guild_id) + + lines = [] + + if current: + duration = current.get("duration", 0) + elapsed = time.time() - current.get("started_at", time.time()) + remaining = max(0, duration - elapsed) + title = current.get("title", "Unknown") + artist = current.get("artist", "") + display = f"{title} ({artist})" if artist else title + lines.append( + f"**Now playing:** {display} ({_format_duration(remaining)} remaining)" + ) + else: + lines.append("Nothing currently playing.") + + if queue: + lines.append("") + lines.append("**Up next:**") + for i, item in enumerate(queue, 1): + title = item.get("title", "Unknown") + artist = item.get("artist", "") + duration = _format_duration(item.get("duration")) + display = f"{title} ({artist})" if artist else title + lines.append(f"{i}. {display} ({duration})") + + return "\n".join(lines) + + +def get_now_playing(guild_id): + current = playing.get(guild_id) + if not current: + return "Nothing currently playing." + + duration = current.get("duration", 0) + elapsed = time.time() - current.get("started_at", time.time()) + remaining = max(0, duration - elapsed) + title = current.get("title", "Unknown") + artist = current.get("artist", "") + display = f"{title} ({artist})" if artist else title + + return f"Currently playing: **{display}**\n{_format_duration(remaining)} remaining" diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..4c3a035 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1,47 @@ +import os +import importlib.util +import logging + +logger = logging.getLogger(__name__) + +def load_plugins(app=None): + """ + Automatically discover and load general plugins from plugins/. + + Each plugin module can have optional hooks: + - register(app) for API routes + - register_commands() for bot commands + - register_tasks() for background tasks + - PLUGIN_NAME, PLUGIN_VERSION for metadata + """ + plugins_dir = os.path.dirname(__file__) + for filename in os.listdir(plugins_dir): + if filename.endswith('.py') and filename != '__init__.py': + module_name = filename[:-3] # remove .py + module_path = os.path.join(plugins_dir, filename) + + try: + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Optional metadata + name = getattr(module, 'PLUGIN_NAME', module_name) + version = getattr(module, 'PLUGIN_VERSION', 'unknown') + + # Apply hooks if available + if app and hasattr(module, 'register'): + module.register(app) + logger.info(f"Loaded plugin routes: {name} v{version}") + + if hasattr(module, 'register_commands'): + module.register_commands() + logger.info(f"Loaded plugin commands: {name} v{version}") + + if hasattr(module, 'register_tasks'): + module.register_tasks() + logger.info(f"Loaded plugin tasks: {name} v{version}") + + logger.info(f"Loaded plugin: {name} v{version}") + except Exception as e: + logger.error(f"Failed to load plugin {module_name}: {e}") diff --git a/plugins/example_plugin.py b/plugins/example_plugin.py new file mode 100644 index 0000000..f13deb4 --- /dev/null +++ b/plugins/example_plugin.py @@ -0,0 +1,44 @@ +""" +Example plugin demonstrating the plugin interface. + +Plugins can: +- Define metadata: PLUGIN_NAME, PLUGIN_VERSION +- Register API routes with register(app) +- Register bot commands with register_commands() +- Register background tasks with register_tasks() +""" + +PLUGIN_NAME = "example_plugin" +PLUGIN_VERSION = "1.0.0" + +def register(app): + """Register API routes.""" + @app.route("/api/example_plugin", methods=["GET"]) + def api_example(): + return {"message": "Hello from example plugin!"}, 200 + +def register_commands(): + """Register bot commands.""" + from bot.command_registry import register_module + import ai.parser as ai_parser + + async def handle_example_plugin(message, session, parsed): + await message.channel.send("Example plugin command executed!") + + def validate_example_plugin_json(data): + return [] # No validation errors + + register_module("example_plugin", handle_example_plugin) + ai_parser.register_validator("example_plugin", validate_example_plugin_json) + +def register_tasks(): + """Register background tasks.""" + # Example: modify the background loop + from bot.bot import backgroundLoop + + async def example_task(): + print("Example plugin background task running") + + # Note: This is a simple example; in practice, you'd need a better way to extend tasks + # For now, plugins can import and modify global state if needed + pass diff --git a/requirements.txt b/requirements.txt index 0761643..ad0516b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,6 @@ PyJWT>=2.8.0 discord.py>=2.3.0 openai>=1.0.0 requests>=2.31.0 +yt-dlp>=2024.0.0 +PyNaCl>=1.5.0 + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..3398dcf --- /dev/null +++ b/static/style.css @@ -0,0 +1,559 @@ +:root { + --bg: #f8f9fa; + --card-bg: #ffffff; + --text: #2d3436; + --text-secondary: #636e72; + --border: #e0e4e8; + --accent: #6c5ce7; + --accent-light: #a29bfe; + --success: #00b894; + --danger: #e17055; + --warning: #fdcb6e; + --streak-bg: #fff3e0; + --habit-1: #6c5ce7; + --habit-2: #00b894; + --habit-3: #e17055; + --habit-4: #0984e3; + --habit-5: #fdcb6e; + --habit-6: #e84393; + --habit-7: #00cec9; + --habit-8: #6ab04c; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.06); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.dark { + --bg: #1a1a2e; + --card-bg: #222244; + --text: #e0e0e0; + --text-secondary: #a0a0b8; + --border: #2e2e52; + --streak-bg: #2a1f3d; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.45); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; + background-color: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + justify-content: center; + padding: 24px 16px 60px; + transition: background-color 0.3s ease, color 0.3s ease; + line-height: 1.5; +} + +.container { + width: 100%; + max-width: 680px; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 28px; + flex-wrap: wrap; + gap: 12px; +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + width: 42px; + height: 42px; + background: linear-gradient(135deg, var(--accent), var(--accent-light)); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: #fff; + box-shadow: 0 4px 10px rgba(108, 92, 231, 0.3); +} + +.header-title h1 { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.3px; + line-height: 1.2; +} + +.header-title .date-display { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; +} + +.header-actions { + display: flex; + gap: 8px; + align-items: center; +} + +/* Buttons */ +.btn { + padding: 10px 18px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + font-family: inherit; + transition: all var(--transition); + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.btn-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 2px 8px rgba(108, 92, 231, 0.3); +} +.btn-primary:hover { + background: #5a4bd1; + box-shadow: 0 4px 14px rgba(108, 92, 231, 0.4); + transform: translateY(-1px); +} + +.btn-outline { + background: transparent; + color: var(--text); + border: 2px solid var(--border); +} +.btn-outline:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(108, 92, 231, 0.05); +} + +.btn-icon { + width: 38px; + height: 38px; + padding: 0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + background: var(--card-bg); + border: 2px solid var(--border); + color: var(--text); + cursor: pointer; + transition: all var(--transition); +} +.btn-icon:hover { + border-color: var(--accent); + color: var(--accent); + box-shadow: var(--shadow-md); +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.78rem; + border-radius: 6px; +} + +.btn-danger-sm { + background: transparent; + border: 1.5px solid var(--danger); + color: var(--danger); + padding: 6px 12px; + font-size: 0.78rem; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-family: inherit; + transition: all var(--transition); +} +.btn-danger-sm:hover { + background: var(--danger); + color: #fff; +} + +/* Stats Cards */ +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 14px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--card-bg); + border-radius: var(--radius-lg); + padding: 18px 16px; + box-shadow: var(--shadow-sm); + text-align: center; + border: 1px solid var(--border); + transition: all var(--transition); + position: relative; + overflow: hidden; +} +.stat-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} +.stat-card .stat-value { + font-size: 2rem; + font-weight: 800; + letter-spacing: -1px; + line-height: 1; + margin-bottom: 4px; +} +.stat-card .stat-label { + font-size: 0.78rem; + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.stat-card.accent .stat-value { + color: var(--accent); +} +.stat-card.success .stat-value { + color: var(--success); +} +.stat-card.warning .stat-value { + color: #e17055; +} + +.stat-card .stat-icon { + position: absolute; + bottom: -8px; + right: -8px; + font-size: 3rem; + opacity: 0.08; +} + +/* Habit list */ +.habit-list-title { + font-size: 1rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-secondary); + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 8px; +} +.habit-list-title::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); + border-radius: 2px; +} + +.habit-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.habit-item { + background: var(--card-bg); + border-radius: var(--radius-lg); + padding: 16px 18px; + display: flex; + align-items: center; + gap: 14px; + box-shadow: var(--shadow-sm); + border: 1px solid var(--border); + transition: all var(--transition); + position: relative; + cursor: pointer; + user-select: none; +} +.habit-item:hover { + box-shadow: var(--shadow-md); + border-color: #ccc; +} +.dark .habit-item:hover { + border-color: #444; +} +.habit-item.completed { + opacity: 0.7; + background: #f0faf5; + border-color: #c8e6d0; +} +.dark .habit-item.completed { + background: #1a2e24; + border-color: #2a4a34; +} + +/* Custom checkbox */ +.habit-checkbox { + width: 26px; + height: 26px; + min-width: 26px; + border-radius: 50%; + border: 2.5px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.25s ease; + font-size: 14px; + color: transparent; + background: transparent; +} +.habit-item.completed .habit-checkbox { + background: var(--success); + border-color: var(--success); + color: #fff; + box-shadow: 0 3px 8px rgba(0, 184, 148, 0.3); +} + +.habit-info { + flex: 1; + min-width: 0; +} +.habit-name { + font-weight: 700; + font-size: 1rem; + letter-spacing: -0.2px; +} +.habit-details { + display: flex; + gap: 10px; + align-items: center; + margin-top: 4px; + flex-wrap: wrap; +} +.habit-streak { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 700; + background: var(--streak-bg); + padding: 3px 10px; + border-radius: 20px; + color: #e17055; +} +.habit-streak .streak-flame { + font-size: 0.85rem; +} +.habit-category { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 8px; + border-radius: 4px; + background: rgba(108, 92, 231, 0.08); + color: var(--accent); +} +.habit-progress-bar-wrap { + flex: 1; + min-width: 60px; + max-width: 100px; + height: 5px; + background: var(--border); + border-radius: 10px; + overflow: hidden; + margin-top: 3px; +} +.habit-progress-bar-fill { + height: 100%; + border-radius: 10px; + transition: width 0.5s ease; + background: var(--success); +} + +.habit-delete { + opacity: 0; + transition: opacity var(--transition); + background: none; + border: none; + cursor: pointer; + font-size: 1.1rem; + color: var(--danger); + padding: 4px 8px; + border-radius: 6px; +} +.habit-item:hover .habit-delete { + opacity: 1; +} +.habit-delete:hover { + background: rgba(225, 112, 85, 0.1); +} + +/* Add habit form */ +.add-habit-section { + margin-top: 20px; + background: var(--card-bg); + border-radius: var(--radius-lg); + padding: 20px; + box-shadow: var(--shadow-md); + border: 2px dashed var(--border); + display: none; + animation: slideDown 0.3s ease; +} +.add-habit-section.visible { + display: block; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.form-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: flex-end; +} +.form-group { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 120px; +} +.form-group label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} +.form-group input, +.form-group select { + padding: 10px 14px; + border: 2px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: inherit; + background: var(--bg); + color: var(--text); + transition: border-color var(--transition); + width: 100%; +} +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.1); +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); +} +.empty-state .empty-icon { + font-size: 3rem; + margin-bottom: 12px; + opacity: 0.6; +} +.empty-state p { + font-weight: 500; + margin-bottom: 4px; +} +.empty-state .sub { + font-size: 0.8rem; + opacity: 0.7; +} + +/* Footer / reset */ +.footer-actions { + display: flex; + justify-content: center; + margin-top: 24px; + gap: 10px; + flex-wrap: wrap; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(100px); + background: #2d3436; + color: #fff; + padding: 12px 24px; + border-radius: 30px; + font-weight: 600; + font-size: 0.85rem; + z-index: 999; + opacity: 0; + transition: all 0.35s ease; + pointer-events: none; + box-shadow: var(--shadow-lg); +} +.dark .toast { + background: #e0e0e0; + color: #1a1a2e; +} +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +/* Responsive */ +@media (max-width: 480px) { + .header { + flex-direction: column; + align-items: flex-start; + } + .header-actions { + width: 100%; + justify-content: flex-end; + } + .stats-row { + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + .stat-card { + padding: 14px 10px; + } + .stat-card .stat-value { + font-size: 1.5rem; + } + .stat-card .stat-label { + font-size: 0.68rem; + } + .form-row { + flex-direction: column; + } + .habit-item { + padding: 12px 14px; + gap: 10px; + } + .habit-delete { + opacity: 1; + } +} diff --git a/templates/New Text Document.txt b/templates/New Text Document.txt new file mode 100644 index 0000000..e69de29 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5c741a7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,479 @@ + + + + + + Habit Tracker + + + + +
+ +
+
+
+
+

Habit Tracker

+ +
+
+
+ + +
+
+ + +
+
+
📋
+
0
+
Total Habits
+
+
+
🎯
+
0
+
Done Today
+
+
+
🔥
+
0
+
Best Streak
+
+
+ + +
Your Habits
+
+ +
+
🌱
+

No habits yet

+

Click "New Habit" to get started!

+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + + +
+ + +
+ + + + \ No newline at end of file