first commit
This commit is contained in:
2
.env
2
.env
@@ -1 +1,3 @@
|
||||
DB_PASS=y8Khu7pJQZq6ywFDIJiqpx4zYmclHGHw
|
||||
COMMAND_CHANNEL_ID=123456789012345678
|
||||
ADMIN_USER_IDS=1111222233334444,5555666677778888
|
||||
@@ -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
|
||||
|
||||
|
||||
13
api/main.py
13
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)
|
||||
|
||||
30
api/routes/__init__.py
Normal file
30
api/routes/__init__.py
Normal file
@@ -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}")
|
||||
56
bot/bot.py
56
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,13 +192,21 @@ 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)
|
||||
parsed = ai_parser.parse(
|
||||
message.content, "command_parser", history=history
|
||||
)
|
||||
|
||||
if discord_id not in message_history:
|
||||
message_history[discord_id] = []
|
||||
@@ -225,6 +235,28 @@ async def routeCommand(message):
|
||||
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.")
|
||||
|
||||
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)
|
||||
|
||||
26
bot/commands/__init__.py
Normal file
26
bot/commands/__init__.py
Normal file
@@ -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}")
|
||||
67
bot/commands/music.py
Normal file
67
bot/commands/music.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
music.py - Prefix commands for music playback.
|
||||
|
||||
Register:
|
||||
!play <query> - 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 <song name or URL>`")
|
||||
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)
|
||||
40
bot/prefix_command_registry.py
Normal file
40
bot/prefix_command_registry.py
Normal file
@@ -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())
|
||||
295
bot/voice_manager.py
Normal file
295
bot/voice_manager.py
Normal file
@@ -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"
|
||||
47
plugins/__init__.py
Normal file
47
plugins/__init__.py
Normal file
@@ -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}")
|
||||
44
plugins/example_plugin.py
Normal file
44
plugins/example_plugin.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
559
static/style.css
Normal file
559
static/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
0
templates/New Text Document.txt
Normal file
0
templates/New Text Document.txt
Normal file
479
templates/index.html
Normal file
479
templates/index.html
Normal file
@@ -0,0 +1,479 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Habit Tracker</title>
|
||||
<link rel="stylesheet" href="../static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo-icon">✅</div>
|
||||
<div class="header-title">
|
||||
<h1>Habit Tracker</h1>
|
||||
<span class="date-display" id="dateDisplay"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-icon" id="themeToggle" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
||||
<button class="btn btn-primary" id="showAddForm">
|
||||
<span>+</span> New Habit
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card accent">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-value" id="totalHabits">0</div>
|
||||
<div class="stat-label">Total Habits</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="stat-icon">🎯</div>
|
||||
<div class="stat-value" id="completedToday">0</div>
|
||||
<div class="stat-label">Done Today</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="stat-icon">🔥</div>
|
||||
<div class="stat-value" id="bestStreak">0</div>
|
||||
<div class="stat-label">Best Streak</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Habit List -->
|
||||
<div class="habit-list-title">Your Habits</div>
|
||||
<div class="habit-list" id="habitList">
|
||||
<!-- Empty state (shown when no habits exist) -->
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-icon">🌱</div>
|
||||
<p>No habits yet</p>
|
||||
<p class="sub">Click "New Habit" to get started!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Habit Form -->
|
||||
<div class="add-habit-section" id="addHabitSection">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex: 2; min-width: 160px;">
|
||||
<label for="habitName">Habit Name</label>
|
||||
<input type="text" id="habitName" placeholder="e.g., Read for 30 min" maxlength="40">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1; min-width: 100px;">
|
||||
<label for="habitCategory">Category</label>
|
||||
<select id="habitCategory">
|
||||
<option value="health">💪 Health</option>
|
||||
<option value="mind">🧠 Mind</option>
|
||||
<option value="productivity">⚡ Productivity</option>
|
||||
<option value="wellness">🧘 Wellness</option>
|
||||
<option value="other">📌 Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0; min-width: auto; align-self: flex-end;">
|
||||
<button class="btn btn-primary" id="addHabitBtn">Add</button>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0; min-width: auto; align-self: flex-end;">
|
||||
<button class="btn btn-outline btn-sm" id="cancelAddBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer-actions">
|
||||
<button class="btn btn-outline btn-sm" id="resetTodayBtn">🔄 Reset Today</button>
|
||||
<button class="btn btn-outline btn-sm" id="resetAllBtn">🗑️ Clear All Habits</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// --- State ---
|
||||
const STORAGE_KEY = 'habitTrackerData_v2';
|
||||
const THEME_KEY = 'habitTrackerTheme';
|
||||
|
||||
let habits = [];
|
||||
let theme = localStorage.getItem(THEME_KEY) || 'light';
|
||||
|
||||
// --- DOM refs ---
|
||||
const habitListEl = document.getElementById('habitList');
|
||||
const emptyStateEl = document.getElementById('emptyState');
|
||||
const totalHabitsEl = document.getElementById('totalHabits');
|
||||
const completedTodayEl = document.getElementById('completedToday');
|
||||
const bestStreakEl = document.getElementById('bestStreak');
|
||||
const dateDisplayEl = document.getElementById('dateDisplay');
|
||||
const addHabitSection = document.getElementById('addHabitSection');
|
||||
const habitNameInput = document.getElementById('habitName');
|
||||
const habitCategorySelect = document.getElementById('habitCategory');
|
||||
const themeToggleBtn = document.getElementById('themeToggle');
|
||||
const toastEl = document.getElementById('toast');
|
||||
|
||||
// --- Load data ---
|
||||
function loadHabits() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
habits = JSON.parse(raw);
|
||||
} else {
|
||||
// Default demo habits
|
||||
habits = [
|
||||
{ id: genId(), name: 'Read for 30 minutes', category: 'mind', streak: 5,
|
||||
completedDates: getRecentDates(5), colorIndex: 0 },
|
||||
{ id: genId(), name: 'Drink 8 glasses of water', category: 'health', streak: 12,
|
||||
completedDates: getRecentDates(12), colorIndex: 1 },
|
||||
{ id: genId(), name: 'Meditate (10 min)', category: 'wellness', streak: 3,
|
||||
completedDates: getRecentDates(3), colorIndex: 2 },
|
||||
{ id: genId(), name: 'No social media before bed', category: 'productivity', streak: 0,
|
||||
completedDates: [], colorIndex: 3 },
|
||||
];
|
||||
saveHabits();
|
||||
}
|
||||
} catch (e) {
|
||||
habits = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHabits() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||
}
|
||||
|
||||
function genId() {
|
||||
return 'h_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
function getRecentDates(n) {
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
dates.push(formatDate(d));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function getTodayStr() {
|
||||
return formatDate(new Date());
|
||||
}
|
||||
|
||||
// --- Theme ---
|
||||
function applyTheme() {
|
||||
if (theme === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
themeToggleBtn.textContent = '☀️';
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
themeToggleBtn.textContent = '🌙';
|
||||
}
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'light' ? 'dark' : 'light';
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
// --- Toast ---
|
||||
let toastTimeout;
|
||||
|
||||
function showToast(msg) {
|
||||
clearTimeout(toastTimeout);
|
||||
toastEl.textContent = msg;
|
||||
toastEl.classList.add('show');
|
||||
toastTimeout = setTimeout(() => {
|
||||
toastEl.classList.remove('show');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
function renderAll() {
|
||||
renderStats();
|
||||
renderHabitList();
|
||||
renderDate();
|
||||
}
|
||||
|
||||
function renderDate() {
|
||||
const now = new Date();
|
||||
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
|
||||
dateDisplayEl.textContent = now.toLocaleDateString('en-US', options);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const todayStr = getTodayStr();
|
||||
totalHabitsEl.textContent = habits.length;
|
||||
const completedToday = habits.filter(h => h.completedDates.includes(todayStr)).length;
|
||||
completedTodayEl.textContent = completedToday;
|
||||
const best = habits.reduce((max, h) => Math.max(max, h.streak || 0), 0);
|
||||
bestStreakEl.textContent = best;
|
||||
}
|
||||
|
||||
function renderHabitList() {
|
||||
// Clear list (keep empty state element)
|
||||
habitListEl.querySelectorAll('.habit-item').forEach(el => el.remove());
|
||||
|
||||
if (habits.length === 0) {
|
||||
emptyStateEl.style.display = '';
|
||||
} else {
|
||||
emptyStateEl.style.display = 'none';
|
||||
const todayStr = getTodayStr();
|
||||
habits.forEach((habit, index) => {
|
||||
const isCompleted = habit.completedDates.includes(todayStr);
|
||||
const el = createHabitElement(habit, index, isCompleted);
|
||||
habitListEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createHabitElement(habit, index, isCompleted) {
|
||||
const colorPalette = [
|
||||
'#6c5ce7', '#00b894', '#e17055', '#0984e3',
|
||||
'#fdcb6e', '#e84393', '#00cec9', '#6ab04c'
|
||||
];
|
||||
const color = colorPalette[habit.colorIndex % colorPalette.length] || colorPalette[0];
|
||||
const categoryEmojis = {
|
||||
health: '💪',
|
||||
mind: '🧠',
|
||||
productivity: '⚡',
|
||||
wellness: '🧘',
|
||||
other: '📌'
|
||||
};
|
||||
const categoryEmoji = categoryEmojis[habit.category] || '📌';
|
||||
const weeklyGoal = 7;
|
||||
const thisWeekCompletions = getThisWeekCompletions(habit);
|
||||
const weeklyProgress = Math.min(100, Math.round((thisWeekCompletions / weeklyGoal) * 100));
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'habit-item' + (isCompleted ? ' completed' : '');
|
||||
div.setAttribute('data-habit-id', habit.id);
|
||||
div.innerHTML = `
|
||||
<div class="habit-checkbox">✓</div>
|
||||
<div class="habit-info">
|
||||
<div class="habit-name">${escapeHtml(habit.name)}</div>
|
||||
<div class="habit-details">
|
||||
<span class="habit-streak">
|
||||
<span class="streak-flame">🔥</span> ${habit.streak || 0} day${habit.streak !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span class="habit-category" style="background: ${color}15; color: ${color};">
|
||||
${categoryEmoji} ${capitalize(habit.category)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="habit-progress-bar-wrap">
|
||||
<div class="habit-progress-bar-fill" style="width: ${weeklyProgress}%; background: ${color};"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="habit-delete" title="Delete habit" data-delete-id="${habit.id}">🗑️</button>
|
||||
`;
|
||||
|
||||
// Click to toggle
|
||||
div.addEventListener('click', (e) => {
|
||||
// Don't toggle if clicking delete button
|
||||
if (e.target.closest('.habit-delete')) return;
|
||||
toggleHabitCompletion(habit.id);
|
||||
});
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = div.querySelector('.habit-delete');
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
deleteHabit(habit.id);
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function getThisWeekCompletions(habit) {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay(); // 0=Sun
|
||||
const startOfWeek = new Date(today);
|
||||
startOfWeek.setDate(today.getDate() - ((dayOfWeek + 6) % 7)); // Monday start
|
||||
let count = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(startOfWeek);
|
||||
d.setDate(startOfWeek.getDate() + i);
|
||||
const dStr = formatDate(d);
|
||||
if (habit.completedDates.includes(dStr)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
function toggleHabitCompletion(habitId) {
|
||||
const habit = habits.find(h => h.id === habitId);
|
||||
if (!habit) return;
|
||||
const todayStr = getTodayStr();
|
||||
const idx = habit.completedDates.indexOf(todayStr);
|
||||
|
||||
if (idx >= 0) {
|
||||
// Uncomplete
|
||||
habit.completedDates.splice(idx, 1);
|
||||
recalculateStreak(habit);
|
||||
showToast('Marked as incomplete');
|
||||
} else {
|
||||
// Complete
|
||||
habit.completedDates.push(todayStr);
|
||||
recalculateStreak(habit);
|
||||
showToast('✅ Habit completed!');
|
||||
}
|
||||
saveHabits();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function recalculateStreak(habit) {
|
||||
// Sort dates descending
|
||||
const sorted = [...new Set(habit.completedDates)].sort().reverse();
|
||||
if (sorted.length === 0) {
|
||||
habit.streak = 0;
|
||||
return;
|
||||
}
|
||||
const todayStr = getTodayStr();
|
||||
const yesterdayStr = formatDate(new Date(Date.now() - 86400000));
|
||||
|
||||
// Streak must include today or yesterday to be active
|
||||
if (sorted[0] !== todayStr && sorted[0] !== yesterdayStr) {
|
||||
habit.streak = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let streak = 0;
|
||||
let checkDate = new Date(sorted[0] + 'T00:00:00');
|
||||
for (const dateStr of sorted) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const diffDays = Math.round((checkDate - d) / 86400000);
|
||||
if (diffDays === 0) {
|
||||
streak++;
|
||||
checkDate = new Date(d.getTime() - 86400000);
|
||||
} else if (diffDays === 1 && streak === 0) {
|
||||
// Allow starting from yesterday
|
||||
streak++;
|
||||
checkDate = new Date(d.getTime() - 86400000);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
habit.streak = streak;
|
||||
}
|
||||
|
||||
function addHabit() {
|
||||
const name = habitNameInput.value.trim();
|
||||
if (!name) {
|
||||
showToast('Please enter a habit name');
|
||||
habitNameInput.focus();
|
||||
return;
|
||||
}
|
||||
const category = habitCategorySelect.value;
|
||||
const newHabit = {
|
||||
id: genId(),
|
||||
name,
|
||||
category,
|
||||
streak: 0,
|
||||
completedDates: [],
|
||||
colorIndex: habits.length,
|
||||
};
|
||||
habits.push(newHabit);
|
||||
saveHabits();
|
||||
renderAll();
|
||||
habitNameInput.value = '';
|
||||
addHabitSection.classList.remove('visible');
|
||||
showToast('🎉 Habit added!');
|
||||
}
|
||||
|
||||
function deleteHabit(habitId) {
|
||||
habits = habits.filter(h => h.id !== habitId);
|
||||
saveHabits();
|
||||
renderAll();
|
||||
showToast('Habit deleted');
|
||||
}
|
||||
|
||||
function resetToday() {
|
||||
const todayStr = getTodayStr();
|
||||
let changed = false;
|
||||
habits.forEach(h => {
|
||||
const idx = h.completedDates.indexOf(todayStr);
|
||||
if (idx >= 0) {
|
||||
h.completedDates.splice(idx, 1);
|
||||
changed = true;
|
||||
}
|
||||
recalculateStreak(h);
|
||||
});
|
||||
if (changed) {
|
||||
saveHabits();
|
||||
renderAll();
|
||||
showToast('🔄 Today\'s progress reset');
|
||||
} else {
|
||||
showToast('Nothing to reset for today');
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (confirm('Are you sure you want to delete all habits? This cannot be undone.')) {
|
||||
habits = [];
|
||||
saveHabits();
|
||||
renderAll();
|
||||
showToast('All habits cleared');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
document.getElementById('showAddForm').addEventListener('click', () => {
|
||||
addHabitSection.classList.toggle('visible');
|
||||
if (addHabitSection.classList.contains('visible')) {
|
||||
habitNameInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('cancelAddBtn').addEventListener('click', () => {
|
||||
addHabitSection.classList.remove('visible');
|
||||
habitNameInput.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('addHabitBtn').addEventListener('click', addHabit);
|
||||
habitNameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') addHabit();
|
||||
});
|
||||
|
||||
document.getElementById('resetTodayBtn').addEventListener('click', resetToday);
|
||||
document.getElementById('resetAllBtn').addEventListener('click', resetAll);
|
||||
themeToggleBtn.addEventListener('click', toggleTheme);
|
||||
|
||||
// --- Init ---
|
||||
loadHabits();
|
||||
applyTheme();
|
||||
renderAll();
|
||||
|
||||
// Re-render stats periodically (in case date rolls over)
|
||||
setInterval(() => {
|
||||
renderStats();
|
||||
renderDate();
|
||||
}, 60000);
|
||||
|
||||
console.log('🌱 Habit Tracker ready!');
|
||||
console.log(' - Click a habit to toggle completion');
|
||||
console.log(' - Streaks auto-calculate');
|
||||
console.log(' - Data saved to localStorage');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user