Inertia migration/UI3 (#911)

* Inertia p1?

* Inertia'fied signed out homepage?

* Split up signed in page

* WIP signed in v2?

* Better signed in?

* Clean up extensions page!

* Fix currently hacking

* Better docs page?

* Docs update 2

* Clean up "What is Hackatime?" + get rid of that godawful green dev mode

* Better nav?

* Cleaner settings?

* Fix commit times

* Fix flashes + OS improv

* Setup v2

* Readd some of the syncers?

* Remove stray emdash

* Clean up Step 3

* Oops, remove .vite

* bye bye, /inertia-example

* bin/rubocop -A

* Fix docs vuln
This commit is contained in:
Mahad Kalam 2026-02-09 11:26:30 +00:00 committed by GitHub
parent f73d268d42
commit ef3f36c829
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
99 changed files with 8791 additions and 1984 deletions

6
.gitignore vendored
View file

@ -43,3 +43,9 @@
.idea .idea
.DS_Store .DS_Store
node_modules
public/vite-dev
public/vite-ssr
.vite

View file

@ -26,6 +26,10 @@ RUN apt-get update -qq && \
wget && \ wget && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash && \
mv ~/.bun/bin/bun /usr/local/bin/
# Set production environment # Set production environment
ENV RAILS_ENV="production" \ ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \ BUNDLE_DEPLOYMENT="1" \
@ -40,6 +44,10 @@ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git pkg-config libpq-dev libyaml-dev && \ apt-get install --no-install-recommends -y build-essential git pkg-config libpq-dev libyaml-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install npm dependencies for Vite
COPY package.json bun.lock ./
RUN bun i --frozen-lockfile
# Install application gems # Install application gems
COPY Gemfile Gemfile.lock ./ COPY Gemfile Gemfile.lock ./
RUN bundle install && \ RUN bundle install && \

View file

@ -1,6 +1,6 @@
FROM ruby:3.4.8 FROM ruby:3.4.8
# Install system dependencies # Install system dependencies including Node.js
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \ apt-get install --no-install-recommends -y \
build-essential \ build-essential \
@ -12,11 +12,21 @@ RUN apt-get update -qq && \
pkg-config \ pkg-config \
curl \ curl \
vim \ vim \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash && \
mv ~/.bun/bin/bun /usr/local/bin/
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Install npm dependencies for Vite
COPY package.json bun.lock ./
RUN bun install
# Install application dependencies # Install application dependencies
COPY Gemfile Gemfile.lock ./ COPY Gemfile Gemfile.lock ./
RUN bundle install RUN bundle install

View file

@ -160,3 +160,7 @@ gem "autotuner", "~> 1.0"
gem "tailwindcss-ruby", "~> 4.1" gem "tailwindcss-ruby", "~> 4.1"
gem "tailwindcss-rails", "~> 4.2" gem "tailwindcss-rails", "~> 4.2"
gem "inertia_rails", "~> 3.17"
gem "vite_rails", "~> 3.0"

View file

@ -136,6 +136,7 @@ GEM
dotenv (= 3.2.0) dotenv (= 3.2.0)
railties (>= 6.1) railties (>= 6.1)
drb (2.2.3) drb (2.2.3)
dry-cli (1.4.1)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.1) erb (6.0.1)
erb_lint (0.9.0) erb_lint (0.9.0)
@ -221,6 +222,8 @@ GEM
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
inertia_rails (3.17.0)
railties (>= 6)
io-console (0.8.2) io-console (0.8.2)
irb (1.16.0) irb (1.16.0)
pp (>= 0.6.0) pp (>= 0.6.0)
@ -278,6 +281,7 @@ GEM
prism (~> 1.5) prism (~> 1.5)
msgpack (1.8.0) msgpack (1.8.0)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0)
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
net-http-persistent (4.0.8) net-http-persistent (4.0.8)
@ -370,6 +374,8 @@ GEM
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.1) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
@ -561,6 +567,15 @@ GEM
uniform_notifier (1.18.0) uniform_notifier (1.18.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
vite_rails (3.0.20)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.9.2)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
web-console (4.2.1) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
@ -612,6 +627,7 @@ DEPENDENCIES
htmlcompressor (~> 0.4.0) htmlcompressor (~> 0.4.0)
http http
importmap-rails importmap-rails
inertia_rails (~> 3.17)
jbuilder jbuilder
kamal kamal
letter_opener letter_opener
@ -652,6 +668,7 @@ DEPENDENCIES
thruster thruster
turbo-rails turbo-rails
tzinfo-data tzinfo-data
vite_rails (~> 3.0)
web-console web-console
BUNDLED WITH BUNDLED WITH

View file

@ -1,2 +1,3 @@
web: bin/rails server -b 0.0.0.0 web: bin/rails server -b 0.0.0.0
css: bin/rails tailwindcss:watch css: bin/rails tailwindcss:watch
vite: bin/vite dev

View file

@ -1,4 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@source "../../../app/javascript/**/*.{svelte,js,ts}";
@source "../../../node_modules/layerchart/dist/**/*.{svelte,js}";
@import "./main.css"; @import "./main.css";
@import "./nav.css"; @import "./nav.css";
@import "./filterable_dashboard.css"; @import "./filterable_dashboard.css";
@ -18,22 +20,31 @@ main {
} }
@theme { @theme {
--color-darker: #121217; --color-darker: #1f1617;
--color-dark: #17171D; --color-dark: #2a1f21;
--color-darkless: #252429; --color-darkless: #4a2d31;
--color-red: #EC3750; --color-red: #c8394f;
--color-orange: #FF8C37; --color-orange: #ff8c37;
--color-yellow: #F1C40F; --color-yellow: #f1c40f;
--color-green: #33D6A6; --color-green: #33d6a6;
--color-cyan: #5BC0DE; --color-cyan: #5bc0de;
--color-blue: #338EDA; --color-blue: #338eda;
--color-purple: #A633D6; --color-purple: #a633d6;
--color-primary: #EC3750; --color-primary: #c8394f;
--color-secondary: #8492A6; --color-secondary: #6e6468;
}
:root.development { --color-muted: #6e6468;
--color-primary: var(--color-green); --color-text-muted: #6e6468;
--color-surface: #2a1f21;
--color-surface-100: #3a292d;
--color-surface-200: #4a3438;
--color-surface-300: #5a3f44;
--color-surface-content: #f3ecee;
--color-info: #5bc0de;
--color-success: #33d6a6;
--color-warning: #f1c40f;
--color-danger: #c8394f;
} }
.project-toggle-group { .project-toggle-group {
@ -72,4 +83,4 @@ main {
.interval-selector-button:hover { .interval-selector-button:hover {
background-color: var(--color-primary); background-color: var(--color-primary);
opacity: 0.9; opacity: 0.9;
} }

View file

@ -1,38 +1,8 @@
@font-face {
font-family: "Phantom Sans";
src: url("/fonts/Regular.woff2") format("woff2"), url("/fonts/Regular.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Phantom Sans";
src: url("/fonts/Italic.woff2") format("woff2"), url("/fonts/Italic.woff") format("woff");
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Phantom Sans";
src: url("/fonts/Bold.woff2") format("woff2"), url("/fonts/Bold.woff") format("woff");
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "DS-Digital";
src: url("/fonts/DS-DIGIB.ttf") format("truetype");
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face { @font-face {
font-family: "clockicons"; font-family: "clockicons";
src: url("/fonts/clockicons.woff2") format("woff2"), url("/fonts/clockicons.ttf") format("truetype"); src:
url("/fonts/clockicons.woff2") format("woff2"),
url("/fonts/clockicons.ttf") format("truetype");
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
} }
@ -47,27 +17,29 @@
} }
@keyframes blink-anim { @keyframes blink-anim {
0%, 50% { 0%,
50% {
opacity: 1; opacity: 1;
} }
51%, 100% { 51%,
100% {
opacity: 0; opacity: 0;
} }
} }
body, html { body,
font-family: "Phantom Sans", "Segoe UI", sans-serif; html {
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI",
sans-serif;
overflow-x: hidden; overflow-x: hidden;
} }
*, *::before, *::after { *,
*::before,
*::after {
font-family: inherit; font-family: inherit;
} }
.ds-digital {
font-family: "DS-Digital", monospace;
}
.clockicons { .clockicons {
font-family: "clockicons", monospace; font-family: "clockicons", monospace;
} }
@ -76,7 +48,10 @@ body, html {
animation: blink-anim 1s infinite; animation: blink-anim 1s infinite;
} }
code, pre, kbd, samp { code,
pre,
kbd,
samp {
@apply font-mono; @apply font-mono;
} }
@ -85,7 +60,9 @@ code, pre, kbd, samp {
} }
/* Ensure forms and inputs respect dark mode */ /* Ensure forms and inputs respect dark mode */
input, textarea, select { input,
textarea,
select {
color-scheme: dark; color-scheme: dark;
} }
@ -115,7 +92,9 @@ select {
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
.animate-spin { .animate-spin {
@ -151,7 +130,8 @@ turbo-frame#mini_leaderboard[aria-busy="true"]::before {
@apply rotate-x-180; @apply rotate-x-180;
} }
.clock-display-front, .clock-display-back { .clock-display-front,
.clock-display-back {
@apply absolute w-full h-full flex justify-center items-center backface-hidden; @apply absolute w-full h-full flex justify-center items-center backface-hidden;
} }
@ -184,4 +164,4 @@ turbo-frame#mini_leaderboard[aria-busy="true"]::before {
.project-desc { .project-desc {
@apply relative bg-[linear-gradient(145deg,#f6dbba_0%,#e6d4be_100%)] rounded-[clamp(6px,1.5vw,10px)] shadow-[0_4px_15px_rgba(0,0,0,0.15),inset_0_1px_0_rgba(255,255,255,0.6),inset_3px_0_5px_rgba(0,0,0,0.05)] transition-all duration-300 ease-in-out border-[2px] border-[rgba(89,47,49,0.3)] overflow-hidden text-black h-full cursor-pointer flex flex-col z-2 mx-5; @apply relative bg-[linear-gradient(145deg,#f6dbba_0%,#e6d4be_100%)] rounded-[clamp(6px,1.5vw,10px)] shadow-[0_4px_15px_rgba(0,0,0,0.15),inset_0_1px_0_rgba(255,255,255,0.6),inset_3px_0_5px_rgba(0,0,0,0.05)] transition-all duration-300 ease-in-out border-[2px] border-[rgba(89,47,49,0.3)] overflow-hidden text-black h-full cursor-pointer flex flex-col z-2 mx-5;
} }

View file

@ -1,24 +1,68 @@
class DocsController < ApplicationController class DocsController < InertiaController
layout "inertia"
POPULAR_EDITORS = [
[ "VS Code", "vs-code" ], [ "PyCharm", "pycharm" ], [ "IntelliJ IDEA", "intellij-idea" ],
[ "Sublime Text", "sublime-text" ], [ "Vim", "vim" ], [ "Neovim", "neovim" ],
[ "Android Studio", "android-studio" ], [ "Xcode", "xcode" ], [ "Unity", "unity" ],
[ "Godot", "godot" ], [ "Cursor", "cursor" ], [ "Zed", "zed" ],
[ "Terminal", "terminal" ], [ "WebStorm", "webstorm" ], [ "Eclipse", "eclipse" ],
[ "Emacs", "emacs" ], [ "Jupyter", "jupyter" ], [ "OnShape", "onshape" ]
].freeze
ALL_EDITORS = [
[ "Android Studio", "android-studio" ], [ "AppCode", "appcode" ], [ "Aptana", "aptana" ],
[ "Arduino IDE", "arduino-ide" ], [ "Azure Data Studio", "azure-data-studio" ],
[ "Blender", "blender" ], [ "Brackets", "brackets" ], [ "Brave", "brave" ],
[ "C++ Builder", "c++-builder" ], [ "Canva", "canva" ], [ "Chrome", "chrome" ],
[ "CLion", "clion" ], [ "Cloud9", "cloud9" ], [ "Coda", "coda" ],
[ "CodeTasty", "codetasty" ], [ "Cursor", "cursor" ], [ "DataGrip", "datagrip" ],
[ "DataSpell", "dataspell" ], [ "DBeaver", "dbeaver" ], [ "Delphi", "delphi" ],
[ "Discord", "discord" ], [ "Eclipse", "eclipse" ], [ "Edge", "edge" ],
[ "Emacs", "emacs" ], [ "Eric", "eric" ], [ "Excel", "excel" ],
[ "Figma", "figma" ], [ "Firefox", "firefox" ], [ "Gedit", "gedit" ],
[ "Godot", "godot" ], [ "GoLand", "goland" ], [ "HBuilder X", "hbuilder-x" ],
[ "IDA Pro", "ida-pro" ], [ "IntelliJ IDEA", "intellij-idea" ], [ "Jupyter", "jupyter" ],
[ "Kakoune", "kakoune" ], [ "Kate", "kate" ], [ "Komodo", "komodo" ],
[ "Micro", "micro" ], [ "MPS", "mps" ], [ "Neovim", "neovim" ],
[ "NetBeans", "netbeans" ], [ "Notepad++", "notepad++" ], [ "Nova", "nova" ],
[ "Obsidian", "obsidian" ], [ "OnShape", "onshape" ], [ "Oxygen", "oxygen" ],
[ "PhpStorm", "phpstorm" ], [ "Postman", "postman" ], [ "PowerPoint", "powerpoint" ],
[ "Processing", "processing" ], [ "Pulsar", "pulsar" ], [ "PyCharm", "pycharm" ],
[ "ReClassEx", "reclassex" ], [ "Rider", "rider" ], [ "Roblox Studio", "roblox-studio" ],
[ "RubyMine", "rubymine" ], [ "RustRover", "rustrover" ], [ "Safari", "safari" ],
[ "SiYuan", "siyuan" ], [ "Sketch", "sketch" ], [ "SlickEdit", "slickedit" ],
[ "SQL Server Management Studio", "sql-server-management-studio" ],
[ "Sublime Text", "sublime-text" ], [ "Terminal", "terminal" ],
[ "TeXstudio", "texstudio" ], [ "TextMate", "textmate" ], [ "Trae", "trae" ],
[ "Unity", "unity" ], [ "Unreal Engine 4", "unreal-engine-4" ],
[ "Vim", "vim" ], [ "Visual Studio", "visual-studio" ], [ "VS Code", "vs-code" ],
[ "WebStorm", "webstorm" ], [ "Windsurf", "windsurf" ], [ "Wing", "wing" ],
[ "Word", "word" ], [ "Xcode", "xcode" ], [ "Zed", "zed" ],
[ "Swift Playgrounds", "swift-playgrounds" ]
].sort_by { |editor| editor[0] }.freeze
# Docs are publicly accessible - no authentication required # Docs are publicly accessible - no authentication required
def index def index
@docs = docs_structure render inertia: "Docs/Index", props: {
popular_editors: POPULAR_EDITORS,
all_editors: ALL_EDITORS
}
end end
def show def show
@doc_path = sanitize_path(params[:path] || "index") doc_path = sanitize_path(params[:path] || "index")
if @doc_path.start_with?("api") if doc_path.start_with?("api")
redirect_to "/api-docs", allow_other_host: false and return redirect_to "/api-docs", allow_other_host: false and return
end end
@breadcrumbs = build_breadcrumbs(@doc_path) file_path = safe_docs_path("#{doc_path}.md")
file_path = safe_docs_path("#{@doc_path}.md")
unless File.exist?(file_path) unless File.exist?(file_path)
# Try with index.md in the directory # Try with index.md in the directory
dir_path = safe_docs_path(@doc_path, "index.md") dir_path = safe_docs_path(doc_path, "index.md")
if File.exist?(dir_path) if File.exist?(dir_path)
file_path = dir_path file_path = dir_path
else else
@ -26,9 +70,23 @@ class DocsController < ApplicationController
end end
end end
@content = read_docs_file(file_path) content = read_docs_file(file_path)
@title = extract_title(@content) || @doc_path.humanize title = extract_title(content) || doc_path.humanize
@rendered_content = render_markdown(@content) rendered_content = render_markdown(content)
breadcrumbs = build_inertia_breadcrumbs(doc_path)
edit_url = "https://github.com/hackclub/hackatime/edit/main/docs/#{doc_path}.md"
render inertia: "Docs/Show", props: {
doc_path: doc_path,
title: title,
rendered_content: rendered_content,
breadcrumbs: breadcrumbs,
edit_url: edit_url,
meta: {
description: generate_doc_description(content, title),
keywords: generate_doc_keywords(doc_path, title)
}
}
rescue => e rescue => e
Rails.logger.error "Error loading docs: #{e.message}" Rails.logger.error "Error loading docs: #{e.message}"
render_not_found render_not_found
@ -40,13 +98,9 @@ class DocsController < ApplicationController
# Remove any directory traversal attempts and normalize path # Remove any directory traversal attempts and normalize path
return "index" if path.blank? return "index" if path.blank?
# Remove leading/trailing slashes and dangerous characters clean_path = path.to_s.split("/").reject(&:empty?).join("/").gsub("..", "")
clean_path = path.to_s.gsub(/\A\/+|\/+\z/, "").gsub(/\.\./, "")
# Only allow alphanumeric characters, hyphens, underscores, plus signs, and forward slashes
clean_path = clean_path.gsub(/[^a-zA-Z0-9\-_+\/]/, "") clean_path = clean_path.gsub(/[^a-zA-Z0-9\-_+\/]/, "")
# Ensure we don't have empty path
clean_path.present? ? clean_path : "index" clean_path.present? ? clean_path : "index"
end end
@ -92,9 +146,9 @@ class DocsController < ApplicationController
structure structure
end end
def build_breadcrumbs(path) def build_inertia_breadcrumbs(path)
parts = path.split("/") parts = path.split("/")
breadcrumbs = [ { name: "Docs", path: docs_path, is_link: true } ] breadcrumbs = [ { name: "Docs", href: docs_path, is_link: true } ]
current_path = "" current_path = ""
parts.each_with_index do |part, index| parts.each_with_index do |part, index|
@ -105,10 +159,11 @@ class DocsController < ApplicationController
File.exist?(safe_docs_path(current_path, "index.md")) File.exist?(safe_docs_path(current_path, "index.md"))
# Only make it a link if the file exists, or if it's the current page (last item) # Only make it a link if the file exists, or if it's the current page (last item)
if file_exists || index == parts.length - 1 is_last = index == parts.length - 1
breadcrumbs << { name: part.titleize, path: doc_path(current_path), is_link: true } if file_exists || is_last
breadcrumbs << { name: part.titleize, href: doc_path(current_path), is_link: !is_last }
else else
breadcrumbs << { name: part.titleize, path: nil, is_link: false } breadcrumbs << { name: part.titleize, href: nil, is_link: false }
end end
end end
@ -159,10 +214,11 @@ class DocsController < ApplicationController
end end
def render_not_found def render_not_found
@status_code = 404 render inertia: "Errors/NotFound", props: {
@title = "Page Not Found" status_code: 404,
@message = "The documentation page you were looking for doesn't exist." title: "Page Not Found",
render "errors/show", status: :not_found, layout: "errors" message: "The documentation page you were looking for doesn't exist."
}, status: :not_found
end end
# Make these helper methods available to views # Make these helper methods available to views

View file

@ -1,4 +1,7 @@
class ExtensionsController < ApplicationController class ExtensionsController < InertiaController
layout "inertia"
def index def index
render inertia: "Extensions/Index"
end end
end end

View file

@ -0,0 +1,161 @@
# frozen_string_literal: true
class InertiaController < ApplicationController
inertia_share layout: -> { inertia_layout_props }
private
def inertia_layout_props
{
nav: inertia_nav_props,
footer: inertia_footer_props,
currently_hacking: currently_hacking_props,
csrf_token: form_authenticity_token,
signout_path: signout_path,
show_stop_impersonating: session[:impersonater_user_id].present?,
stop_impersonating_path: stop_impersonating_path
}
end
def inertia_nav_props
{
flash: inertia_flash_messages,
user_present: current_user.present?,
user_mention_html: current_user ? render_to_string(partial: "shared/user_mention", locals: { user: current_user }) : nil,
streak_html: current_user ? render_to_string(partial: "static_pages/streak", locals: { user: current_user, show_text: true, turbo_frame: false }) : nil,
admin_level_html: current_user ? render_to_string(partial: "static_pages/admin_level", locals: { user: current_user }) : nil,
login_path: slack_auth_path,
links: inertia_primary_links,
dev_links: inertia_dev_links,
admin_links: inertia_admin_links,
viewer_links: inertia_viewer_links,
superadmin_links: inertia_superadmin_links,
activities_html: inertia_activities_html
}
end
def inertia_flash_messages
flash.to_hash.map do |type, message|
{
message: message.to_s,
class_name: type.to_sym == :notice ? "border-green text-green" : "border-primary text-primary"
}
end
end
def inertia_primary_links
links = []
links << inertia_link("Home", root_path, active: helpers.current_page?(root_path))
links << inertia_link("Leaderboards", leaderboards_path, active: helpers.current_page?(leaderboards_path))
if current_user
links << inertia_link("Projects", my_projects_path, active: helpers.current_page?(my_projects_path))
links << inertia_link("Docs", docs_path, active: helpers.current_page?(docs_path) || request.path.start_with?("/docs"))
links << inertia_link("Extensions", extensions_path, active: helpers.current_page?(extensions_path))
links << inertia_link("Settings", my_settings_path, active: helpers.current_page?(my_settings_path))
links << inertia_link("My OAuth Apps", oauth_applications_path, active: helpers.current_page?(oauth_applications_path) || request.path.start_with?("/oauth/applications"))
links << { label: "Logout", action: "logout" }
else
links << inertia_link("Docs", docs_path, active: helpers.current_page?(docs_path) || request.path.start_with?("/docs"))
links << inertia_link("Extensions", extensions_path, active: helpers.current_page?(extensions_path))
links << inertia_link("What is Hackatime?", "/what-is-hackatime", active: helpers.current_page?("/what-is-hackatime"))
end
links
end
def inertia_dev_links
return [] unless Rails.env.development?
[
inertia_link("Letter Opener", letter_opener_web_path, active: helpers.current_page?(letter_opener_web_path)),
inertia_link("Mailers", "/rails/mailers", active: helpers.current_page?("/rails/mailers"))
]
end
def inertia_admin_links
return [] unless current_user&.admin_level.in?(%w[admin superadmin])
links = []
links << inertia_link("Review Timeline", admin_timeline_path, active: helpers.current_page?(admin_timeline_path))
links << inertia_link("Trust Level Logs", admin_trust_level_audit_logs_path, active: helpers.current_page?(admin_trust_level_audit_logs_path) || request.path.start_with?("/admin/trust_level_audit_logs"))
links << inertia_link("Admin API Keys", admin_admin_api_keys_path, active: helpers.current_page?(admin_admin_api_keys_path) || request.path.start_with?("/admin/admin_api_keys"))
links
end
def inertia_viewer_links
return [] unless current_user&.admin_level == "viewer"
[
inertia_link("Review Timeline", admin_timeline_path, active: helpers.current_page?(admin_timeline_path)),
inertia_link("Trust Level Logs", admin_trust_level_audit_logs_path, active: helpers.current_page?(admin_trust_level_audit_logs_path) || request.path.start_with?("/admin/trust_level_audit_logs")),
inertia_link("Admin API Keys", admin_admin_api_keys_path, active: helpers.current_page?(admin_admin_api_keys_path) || request.path.start_with?("/admin/admin_api_keys"))
]
end
def inertia_superadmin_links
return [] unless current_user&.admin_level == "superadmin"
links = []
links << inertia_link("Admin Management", admin_admin_users_path, active: helpers.current_page?(admin_admin_users_path))
pending_count = DeletionRequest.pending.count
links << inertia_link("Account Deletions", admin_deletion_requests_path, active: helpers.current_page?(admin_deletion_requests_path), badge: pending_count.positive? ? pending_count : nil)
links << inertia_link("GoodBoy", good_job_path, active: helpers.current_page?(good_job_path))
links << inertia_link("All OAuth Apps", admin_oauth_applications_path, active: helpers.current_page?(admin_oauth_applications_path) || request.path.start_with?("/admin/oauth_applications"))
links << inertia_link("Feature Flags", flipper_path, active: helpers.current_page?(flipper_path))
links
end
def inertia_link(label, href, active: false, badge: nil)
{ label: label, href: href, active: active, badge: badge }
end
def inertia_activities_html
return nil unless defined?(@activities) && @activities.present?
helpers.render_activities(@activities)
end
def inertia_footer_props
helpers = ApplicationController.helpers
cache = helpers.cache_stats
hours = active_users_graph_data.map.with_index do |entry, index|
{
height: entry[:height],
title: "#{helpers.pluralize(index + 1, 'hour')} ago, #{helpers.pluralize(entry[:users], 'people')} logged time. '#{FlavorText.latin_phrases.sample}.'"
}
end
{
git_version: Rails.application.config.git_version,
commit_link: Rails.application.config.commit_link,
server_start_time_ago: helpers.time_ago_in_words(Rails.application.config.server_start_time),
heartbeat_recent_count: Heartbeat.recent_count,
heartbeat_recent_imported_count: Heartbeat.recent_imported_count,
query_count: QueryCount::Counter.counter,
query_cache_count: QueryCount::Counter.counter_cache,
cache_hits: cache[:hits],
cache_misses: cache[:misses],
requests_per_second: helpers.requests_per_second,
active_users_graph: hours
}
end
def currently_hacking_props
data = Cache::CurrentlyHackingJob.perform_now
users = (data[:users] || []).map do |u|
proj = data[:active_projects]&.dig(u.id)
{
id: u.id,
display_name: u.display_name,
slack_uid: u.slack_uid,
avatar_url: u.avatar_url,
active_project: proj && { name: proj.project_name, repo_url: proj.repo_url }
}
end
{
count: users.size,
users: users,
interval: 30_000
}
end
end

View file

@ -151,7 +151,8 @@ class SessionsController < ApplicationController
if Rails.env.production? if Rails.env.production?
HandleEmailSigninJob.perform_later(email, continue_param) HandleEmailSigninJob.perform_later(email, continue_param)
else else
HandleEmailSigninJob.perform_now(email, continue_param) token = HandleEmailSigninJob.perform_now(email, continue_param)
session[:dev_magic_link] = auth_token_url(token)
end end
redirect_to root_path(sign_in_email: true), notice: "Check your email for a sign-in link!" redirect_to root_path(sign_in_email: true), notice: "Check your email for a sign-in link!"

View file

@ -1,4 +1,5 @@
class StaticPagesController < ApplicationController class StaticPagesController < InertiaController
layout "inertia", only: :index
before_action :ensure_current_user, only: %i[ before_action :ensure_current_user, only: %i[
filterable_dashboard filterable_dashboard
filterable_dashboard_content filterable_dashboard_content
@ -15,7 +16,7 @@ class StaticPagesController < ApplicationController
return redirect_to "/my/projects?interval=custom&from=#{d}&to=#{d}" if d return redirect_to "/my/projects?interval=custom&from=#{d}&to=#{d}" if d
end end
if current_user.heartbeats.empty? || params[:show_wakatime_setup_notice] if !current_user.heartbeats.exists? || params[:show_wakatime_setup_notice]
@show_wakatime_setup_notice = true @show_wakatime_setup_notice = true
if (ssp = Cache::SetupSocialProofJob.perform_now) if (ssp = Cache::SetupSocialProofJob.perform_now)
@ssp_message, @ssp_users_recent, @ssp_users_size = ssp.values_at(:message, :users_recent, :users_size) @ssp_message, @ssp_users_recent, @ssp_users_size = ssp.values_at(:message, :users_recent, :users_size)
@ -46,6 +47,8 @@ class StaticPagesController < ApplicationController
@todays_duration = current_user.heartbeats.today.duration_seconds @todays_duration = current_user.heartbeats.today.duration_seconds
@show_logged_time_sentence = @todays_duration > 1.minute && (@todays_languages.any? || @todays_editors.any?) @show_logged_time_sentence = @todays_duration > 1.minute && (@todays_languages.any? || @todays_editors.any?)
end end
render inertia: "Home/SignedIn", props: signed_in_props
else else
# Set homepage SEO content for logged-out users only # Set homepage SEO content for logged-out users only
set_homepage_seo_content set_homepage_seo_content
@ -53,6 +56,8 @@ class StaticPagesController < ApplicationController
@usage_social_proof = Cache::UsageSocialProofJob.perform_now @usage_social_proof = Cache::UsageSocialProofJob.perform_now
@home_stats = Cache::HomeStatsJob.perform_now @home_stats = Cache::HomeStatsJob.perform_now
render inertia: "Home/SignedOut", props: signed_out_props
end end
end end
@ -67,12 +72,6 @@ class StaticPagesController < ApplicationController
@meta_keywords = "what is hackatime, hackatime definition, hack club time tracker, coding time tracker, programming statistics" @meta_keywords = "what is hackatime, hackatime definition, hack club time tracker, coding time tracker, programming statistics"
end end
def mini_leaderboard
@leaderboard = LeaderboardService.get(period: :daily, date: Date.current)
@active_projects = Cache::ActiveProjectsJob.perform_now
render partial: "leaderboards/mini_leaderboard", locals: { leaderboard: @leaderboard, current_user: current_user }
end
def project_durations def project_durations
return unless current_user return unless current_user
@ -107,18 +106,6 @@ class StaticPagesController < ApplicationController
render partial: "project_durations", locals: { project_durations: durations, show_archived: archived } render partial: "project_durations", locals: { project_durations: durations, show_archived: archived }
end end
def activity_graph
return unless current_user
tz = current_user.timezone
key = "user_#{current_user.id}_daily_durations_#{tz}"
durations = Rails.cache.fetch(key, expires_in: 1.minute) do
Time.use_zone(tz) { current_user.heartbeats.daily_durations(user_timezone: tz).to_h }
end
render partial: "activity_graph", locals: { daily_durations: durations, length_of_busiest_day: 8.hours.to_i, user_tz: tz }
end
def currently_hacking def currently_hacking
data = Cache::CurrentlyHackingJob.perform_now data = Cache::CurrentlyHackingJob.perform_now
respond_to do |format| respond_to do |format|
@ -178,9 +165,45 @@ class StaticPagesController < ApplicationController
@meta_keywords = "coding time tracker, programming stats, open source time tracker, hack club coding tracker, free time tracking, code statistics, high school programming, coding analytics" @meta_keywords = "coding time tracker, programming stats, open source time tracker, hack club coding tracker, free time tracking, code statistics, high school programming, coding analytics"
end end
def signed_in_props
helpers = ApplicationController.helpers
{
flavor_text: @flavor_text.to_s,
trust_level_red: current_user&.trust_level == "red",
show_wakatime_setup_notice: !!@show_wakatime_setup_notice,
ssp_message: @ssp_message,
ssp_users_recent: @ssp_users_recent || [],
ssp_users_size: @ssp_users_size || @ssp_users_recent&.size || 0,
github_uid_blank: current_user&.github_uid.blank?,
github_auth_path: github_auth_path,
wakatime_setup_path: my_wakatime_setup_path,
show_logged_time_sentence: !!@show_logged_time_sentence,
todays_duration_display: helpers.short_time_detailed(@todays_duration.to_i),
todays_languages: @todays_languages || [],
todays_editors: @todays_editors || [],
filterable_dashboard_data: filterable_dashboard_data,
activity_graph: activity_graph_data
}
end
def signed_out_props
{
flavor_text: @flavor_text.to_s,
hca_auth_path: hca_auth_path,
slack_auth_path: slack_auth_path,
email_auth_path: email_auth_path,
sign_in_email: params[:sign_in_email].present?,
show_dev_tool: Rails.env.development?,
dev_magic_link: (Rails.env.development? ? session.delete(:dev_magic_link) : nil),
csrf_token: form_authenticity_token,
home_stats: @home_stats || {}
}
end
def filterable_dashboard_data def filterable_dashboard_data
filters = %i[project language operating_system editor category] filters = %i[project language operating_system editor category]
key = [ current_user ] + filters.map { |f| params[f] } + [ params[:interval], params[:from], params[:to] ] interval = params[:interval]
key = [ current_user ] + filters.map { |f| params[f] } + [ interval.to_s, params[:from], params[:to] ]
hb = current_user.heartbeats hb = current_user.heartbeats
h = ApplicationController.helpers h = ApplicationController.helpers
@ -190,9 +213,9 @@ class StaticPagesController < ApplicationController
Time.use_zone(current_user.timezone) do Time.use_zone(current_user.timezone) do
filters.each do |f| filters.each do |f|
durations = current_user.heartbeats.group(f).duration_seconds options = current_user.heartbeats.distinct.pluck(f).compact_blank
durations = durations.reject { |n, _| archived.include?(n) } if f == :project options = options.reject { |n| archived.include?(n) } if f == :project
result[f] = durations.sort_by { |_, v| -v }.map(&:first).compact_blank.map { |k| result[f] = options.map { |k|
f == :language ? k.categorize_language : (%i[operating_system editor].include?(f) ? k.capitalize : k) f == :language ? k.categorize_language : (%i[operating_system editor].include?(f) ? k.capitalize : k)
}.uniq }.uniq
@ -209,8 +232,7 @@ class StaticPagesController < ApplicationController
result["singular_#{f}"] = arr.length == 1 result["singular_#{f}"] = arr.length == 1
end end
hb = hb.filter_by_time_range(params[:interval], params[:from], params[:to]) hb = hb.filter_by_time_range(interval, params[:from], params[:to])
result[:filtered_heartbeats] = hb
result[:total_time] = hb.group(:project).duration_seconds.values.sum result[:total_time] = hb.group(:project).duration_seconds.values.sum
result[:total_heartbeats] = hb.count result[:total_heartbeats] = hb.count
@ -247,13 +269,35 @@ class StaticPagesController < ApplicationController
}.to_h }.to_h
end end
result[:weekly_project_stats] = (0..25).to_h do |w| result[:weekly_project_stats] = (0..11).to_h do |w|
ws = w.weeks.ago.beginning_of_week ws = w.weeks.ago.beginning_of_week
[ ws.to_date.iso8601, hb.where(time: ws.to_f..w.weeks.ago.end_of_week.to_f) [ ws.to_date.iso8601, hb.where(time: ws.to_f..w.weeks.ago.end_of_week.to_f)
.group(:project).duration_seconds.reject { |p, _| archived.include?(p) } ] .group(:project).duration_seconds.reject { |p, _| archived.include?(p) } ]
end end
end end
result[:selected_interval] = interval.to_s
result[:selected_from] = params[:from].to_s
result[:selected_to] = params[:to].to_s
filters.each { |f| result["selected_#{f}"] = params[f]&.split(",") || [] }
result result
end end
end end
def activity_graph_data
tz = current_user.timezone
key = "user_#{current_user.id}_daily_durations_#{tz}"
durations = Rails.cache.fetch(key, expires_in: 1.minute) do
Time.use_zone(tz) { current_user.heartbeats.daily_durations(user_timezone: tz).to_h }
end
{
start_date: 365.days.ago.to_date.iso8601,
end_date: Time.current.to_date.iso8601,
duration_by_date: durations.transform_keys { |d| d.to_date.iso8601 }.transform_values(&:to_i),
busiest_day_seconds: 8.hours.to_i,
timezone_label: ActiveSupport::TimeZone[tz].to_s,
timezone_settings_path: "/my/settings#user_timezone"
}
end
end end

View file

@ -1,4 +1,6 @@
class UsersController < ApplicationController class UsersController < InertiaController
layout "inertia"
include ActionView::Helpers::NumberHelper include ActionView::Helpers::NumberHelper
before_action :set_user before_action :set_user
@ -52,25 +54,38 @@ class UsersController < ApplicationController
def wakatime_setup def wakatime_setup
api_key = current_user&.api_keys&.last api_key = current_user&.api_keys&.last
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key") api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
@current_user_api_key = api_key&.token setup_os = detect_setup_os(request.user_agent)
render inertia: "WakatimeSetup/Index", props: {
current_user_api_key: api_key&.token,
setup_os: setup_os.to_s,
api_url: api_hackatime_v1_url,
heartbeat_check_url: api_v1_my_heartbeats_most_recent_path(source_type: "test_entry")
}
end end
def wakatime_setup_step_2 def wakatime_setup_step_2
render inertia: "WakatimeSetup/Step2", props: {}
end end
def wakatime_setup_step_3 def wakatime_setup_step_3
api_key = current_user&.api_keys&.last api_key = current_user&.api_keys&.last
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key") api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
@current_user_api_key = api_key&.token editor = params[:editor]
render inertia: "WakatimeSetup/Step3", props: {
current_user_api_key: api_key&.token,
editor: editor,
heartbeat_check_url: api_v1_my_heartbeats_most_recent_path
}
end end
def wakatime_setup_step_4 def wakatime_setup_step_4
@no_instruction_wording = [ render inertia: "WakatimeSetup/Step4", props: {
"There is no step 4, lol.", dino_video_url: FlavorText.dino_meme_videos.sample,
"There is no step 4, psych!", return_url: session.dig(:return_data, "url"),
"Tricked ya! There is no step 4.", return_button_text: session.dig(:return_data, "button_text") || "Done"
"There is no step 4, gotcha!" }
].sample
end end
def update_trust_level def update_trust_level
@ -125,6 +140,14 @@ class UsersController < ApplicationController
end end
end end
def detect_setup_os(user_agent)
ua = user_agent.to_s
return :windows if ua.match?(/windows/i)
:mac_linux
end
def prepare_settings_page def prepare_settings_page
@is_own_settings = is_own_settings? @is_own_settings = is_own_settings?
@can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write") @can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write")

View file

@ -6,7 +6,7 @@ function setupCurrentlyHacking() {
const header = document.querySelector('.currently-hacking'); const header = document.querySelector('.currently-hacking');
// only if no existing event listener // only if no existing event listener
if (!header) { return } if (!header) { return }
header.onclick = function() { header.onclick = function () {
const container = document.querySelector('.currently-hacking-container'); const container = document.querySelector('.currently-hacking-container');
if (container) { if (container) {
container.classList.toggle('visible'); container.classList.toggle('visible');
@ -19,78 +19,21 @@ function outta() {
const modal = document.getElementById('logout-modal'); const modal = document.getElementById('logout-modal');
if (!modal) return; if (!modal) return;
window.showLogout = function() { window.showLogout = function () {
modal.dispatchEvent(new CustomEvent('modal:open', { bubbles: true })); modal.dispatchEvent(new CustomEvent('modal:open', { bubbles: true }));
}; };
} }
function weirdclockthing() {
const clock = document.getElementById('clock');
if (!clock) return;
clock.innerHTML = '';
function write(element, something) {
element.innerHTML = '';
Array.from(something).forEach((char) => {
const span = document.createElement('span');
span.textContent = char === ' ' ? '\u00A0' : char;
if (char === ':') {
span.classList.add('blink');
}
element.appendChild(span);
});
}
const inner = document.createElement('div');
inner.className = 'clock-display-inner';
const front = document.createElement('div');
// kinda janky lol
front.className = 'clock-display-front ds-digital';
write(front, "HAC:KA:TIME");
const back = document.createElement('div');
back.className = 'clock-display-back';
inner.appendChild(front);
inner.appendChild(back);
clock.appendChild(inner);
function updateClock() {
const date = new Date();
const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
write(back, ` ${time} `);
}
let intervalId = null;
clock.onmouseenter = function () {
updateClock();
if (!intervalId) {
intervalId = setInterval(updateClock, 1000);
}
}
clock.onmouseleave = function () {
clearInterval(intervalId);
intervalId = null;
}
}
// Handle both initial page load and subsequent Turbo navigations // Handle both initial page load and subsequent Turbo navigations
document.addEventListener('turbo:load', function() { document.addEventListener('turbo:load', function () {
setupCurrentlyHacking(); setupCurrentlyHacking();
outta(); outta();
weirdclockthing();
}); });
document.addEventListener('turbo:render', function() { document.addEventListener('turbo:render', function () {
setupCurrentlyHacking(); setupCurrentlyHacking();
outta(); outta();
weirdclockthing();
}); });
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
setupCurrentlyHacking(); setupCurrentlyHacking();
outta(); outta();
weirdclockthing();
}); });

View file

@ -43,7 +43,7 @@ export default class extends Controller {
showLoading() { showLoading() {
this.contentTarget.innerHTML = ` this.contentTarget.innerHTML = `
<div class="p-4"> <div class="p-4 bg-dark">
<div class="text-center text-muted text-md">Loading...</div> <div class="text-center text-muted text-md">Loading...</div>
</div> </div>
` `
@ -113,7 +113,7 @@ export default class extends Controller {
} catch (error) { } catch (error) {
console.error("Failed to poll currently hacking:", error) console.error("Failed to poll currently hacking:", error)
this.contentTarget.innerHTML = ` this.contentTarget.innerHTML = `
<div class="p-4 bg-elevated"> <div class="p-4 bg-dark">
<div class="text-center text-muted text-sm">ruh ro, something broke :(</div> <div class="text-center text-muted text-sm">ruh ro, something broke :(</div>
</div> </div>
` `
@ -132,7 +132,7 @@ export default class extends Controller {
r(u) { r(u) {
if (!u || u.length === 0) { if (!u || u.length === 0) {
this.contentTarget.innerHTML = ` this.contentTarget.innerHTML = `
<div class="p-4 bg-elevated"> <div class="p-4 bg-dark">
<div class="text-center text-muted text-sm italic">No one is currently hacking :(</div> <div class="text-center text-muted text-sm italic">No one is currently hacking :(</div>
</div> </div>
` `

View file

@ -0,0 +1,4 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';

View file

@ -0,0 +1,28 @@
// To see this message, add the following to the `<head>` section in your
// views/layouts/application.html.erb
//
// <%= vite_client_tag %>
// <%= vite_javascript_tag 'application' %>
console.log('Vite ⚡️ Rails')
// If using a TypeScript entrypoint file:
// <%= vite_typescript_tag 'application' %>
//
// If you want to use .jsx or .tsx, add the extension:
// <%= vite_javascript_tag 'application.jsx' %>
console.log('Visit the guide for more information: ', 'https://vite-ruby.netlify.app/guide/rails')
// Example: Load Rails libraries in Vite.
//
// import * as Turbo from '@hotwired/turbo'
// Turbo.start()
//
// import ActiveStorage from '@rails/activestorage'
// ActiveStorage.start()
//
// // Import all channels.
// const channels = import.meta.glob('./**/*_channel.js', { eager: true })
// Example: Import a stylesheet in app/frontend/index.css
// import '~/index.css'

View file

@ -0,0 +1,46 @@
import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte'
import { mount } from 'svelte'
import AppLayout from '../layouts/AppLayout.svelte'
createInertiaApp({
// Disable progress bar
//
// see https://inertia-rails.dev/guide/progress-indicators
// progress: false,
resolve: (name) => {
const pages = import.meta.glob<ResolvedComponent>('../pages/**/*.svelte', {
eager: true,
})
const page = pages[`../pages/${name}.svelte`]
if (!page) {
console.error(`Missing Inertia page component: '${name}.svelte'`)
}
return { default: page.default, layout: page.layout || AppLayout } as ResolvedComponent
},
setup({ el, App, props }) {
if (el) {
mount(App, { target: el, props })
} else {
console.error(
'Missing root element.\n\n' +
'If you see this error, it probably means you load Inertia.js on non-Inertia pages.\n' +
'Consider moving <%= vite_typescript_tag "inertia" %> to the Inertia-specific layout instead.',
)
}
},
defaults: {
form: {
forceIndicesArrayFormatInFormData: false,
},
future: {
useScriptElementForInitialPage: true,
useDataInertiaHeadAttribute: true,
useDialogForErrorModal: true,
preserveEqualProps: true,
},
},
})

View file

@ -0,0 +1,5 @@
import "@hotwired/turbo-rails"
import "@fontsource/inter/400.css"
import "@fontsource/inter/500.css"
import "@fontsource/inter/600.css"
import "@fontsource/inter/700.css"

View file

@ -0,0 +1,505 @@
<script lang="ts">
import { router } from "@inertiajs/svelte";
import { usePoll } from "@inertiajs/svelte";
import { onMount, onDestroy } from "svelte";
import plur from "plur";
type NavLink = {
label: string;
href?: string;
active?: boolean;
badge?: number | null;
action?: string;
};
type LayoutNav = {
flash: { message: string; class_name: string }[];
user_present: boolean;
user_mention_html?: string | null;
streak_html?: string | null;
admin_level_html?: string | null;
login_path: string;
links: NavLink[];
dev_links: NavLink[];
admin_links: NavLink[];
viewer_links: NavLink[];
superadmin_links: NavLink[];
activities_html?: string | null;
};
type Footer = {
git_version: string;
commit_link: string;
server_start_time_ago: string;
heartbeat_recent_count: number;
heartbeat_recent_imported_count: number;
query_count: number;
query_cache_count: number;
cache_hits: number;
cache_misses: number;
requests_per_second: string;
active_users_graph: { height: number; title: string }[];
};
type CurrentlyHackingUser = {
id: number;
display_name?: string;
slack_uid?: string;
avatar_url?: string;
active_project?: { name: string; repo_url?: string | null };
};
type LayoutProps = {
nav: LayoutNav;
footer: Footer;
currently_hacking: {
count: number;
users: CurrentlyHackingUser[];
interval: number;
};
csrf_token: string;
signout_path: string;
show_stop_impersonating: boolean;
stop_impersonating_path: string;
};
let { layout, children }: { layout: LayoutProps; children?: () => unknown } =
$props();
const isBrowser =
typeof window !== "undefined" && typeof document !== "undefined";
let navOpen = $state(false);
let logoutOpen = $state(false);
let currentlyExpanded = $state(false);
const toggleNav = () => (navOpen = !navOpen);
const closeNav = () => (navOpen = false);
const openLogout = () => (logoutOpen = true);
const closeLogout = () => (logoutOpen = false);
usePoll(layout.currently_hacking?.interval || 30000, {
only: ["currently_hacking"],
});
const handleNavLinkClick = () => {
if (isBrowser && window.innerWidth <= 1024) closeNav();
};
const handleResize = () => {
if (isBrowser && window.innerWidth > 1024) closeNav();
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeNav();
closeLogout();
}
};
const countLabel = () =>
`${layout.currently_hacking.count} ${plur("person", layout.currently_hacking.count)} currently hacking`;
const visualizeGitUrl = (url?: string | null) =>
url?.startsWith("https://github.com/")
? url.replace(
"https://github.com/",
"https://tkww0gcc0gkwwo4gc8kgs0sw.a.selfhosted.hackclub.com/",
)
: "";
const toggleCurrentlyHacking = () => {
currentlyExpanded = !currentlyExpanded;
};
$effect(() => {
if (isBrowser) document.body.classList.toggle("overflow-hidden", navOpen);
});
onMount(() => {
if (!isBrowser) return;
handleResize();
window.addEventListener("resize", handleResize);
document.addEventListener("keydown", handleKeydown);
});
onDestroy(() => {
if (isBrowser) {
window.removeEventListener("resize", handleResize);
document.removeEventListener("keydown", handleKeydown);
}
});
const navLinkClass = (active?: boolean) =>
`block px-3 py-2 rounded-md text-sm transition-colors ${active ? "bg-primary text-white" : "hover:bg-darkless"}`;
</script>
{#if layout.nav.flash.length > 0}
<div class="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4 space-y-2">
{#each layout.nav.flash as item}
<div
class={`rounded-md text-center text-sm px-4 py-3 shadow-lg ${item.class_name}`}
>
{item.message}
</div>
{/each}
</div>
{/if}
{#if layout.nav.user_present}
<button
class="mobile-nav-button"
aria-label="Toggle navigation menu"
aria-expanded={navOpen}
onclick={toggleNav}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<div class="nav-overlay" class:open={navOpen} onclick={closeNav}></div>
<aside
class="flex flex-col min-h-screen w-52 bg-dark text-white px-3 py-4 rounded-r-lg overflow-y-auto lg:block"
data-nav-target="nav"
class:open={navOpen}
style="scrollbar-width: none; -ms-overflow-style: none;"
>
<div class="space-y-4">
{#if layout.nav.user_present}
<div
class="flex flex-col items-center gap-2 pb-3 border-b border-darkless"
>
{#if layout.nav.user_mention_html}{@html layout.nav
.user_mention_html}{/if}
{#if layout.nav.streak_html}{@html layout.nav.streak_html}{/if}
{#if layout.nav.admin_level_html}{@html layout.nav
.admin_level_html}{/if}
</div>
{:else}
<div>
<a
href={layout.nav.login_path}
class="block px-4 py-2 rounded-md transition text-white font-semibold bg-primary hover:bg-secondary text-center"
>Login</a
>
</div>
{/if}
<nav class="space-y-1">
{#each layout.nav.links as link}
{#if link.action === "logout"}
<a
type="button"
onclick={openLogout}
class={`${navLinkClass(false)} cursor-pointer`}>Logout</a
>
{:else}
<a
href={link.href}
onclick={handleNavLinkClick}
class={navLinkClass(link.active)}>{link.label}</a
>
{/if}
{/each}
{#if layout.nav.dev_links.length > 0 || layout.nav.admin_links.length > 0 || layout.nav.viewer_links.length > 0 || layout.nav.superadmin_links.length > 0}
<div class="pt-2 mt-2 border-t border-darkless space-y-1">
{#each layout.nav.dev_links as link}
<a
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} dev-tool"
>
{link.label}
{#if link.badge}
<span
class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-primary text-white font-medium"
>
{link.badge}
</span>
{/if}
</a>
{/each}
{#each layout.nav.admin_links as link}
<a
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} admin-tool"
>
{link.label}
{#if link.badge}
<span
class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-primary text-white font-medium"
>
{link.badge}
</span>
{/if}
</a>
{/each}
{#each layout.nav.viewer_links as link}
<a
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} viewer-tool"
>
{link.label}
{#if link.badge}
<span
class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-primary text-white font-medium"
>
{link.badge}
</span>
{/if}
</a>
{/each}
{#each layout.nav.superadmin_links as link}
<a
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} superadmin-tool"
>
{link.label}
{#if link.badge}
<span
class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-primary text-white font-medium"
>
{link.badge}
</span>
{/if}
</a>
{/each}
</div>
{/if}
{#if layout.nav.activities_html}
<div class="pt-2">{@html layout.nav.activities_html}</div>
{/if}
</nav>
</div>
</aside>
{/if}
<main
class={`flex-1 min-h-screen transition-all duration-300 ease-in-out ${layout.nav.user_present ? "lg:ml-62.5" : ""}`}
>
<div class="w-full max-w-7xl mx-auto p-4 pt-16 lg:pt-8 md:p-8">
{@render children?.()}
<footer
class="relative w-full mt-12 mb-5 p-2.5 text-center text-xs text-text-muted"
>
<div class="container mx-auto">
<p
class="brightness-60 hover:brightness-100 transition-all duration-200"
>
Using Inertia. Build <a
href={layout.footer.commit_link}
class="text-inherit underline opacity-80 hover:opacity-100 transition-opacity duration-200"
>{layout.footer.git_version}</a
>
from {layout.footer.server_start_time_ago} ago.
{plur("heartbeat", layout.footer.heartbeat_recent_count)}
({layout.footer.heartbeat_recent_imported_count} imported) in the past
24 hours. (DB: {layout.footer.query_count}
{plur("query", layout.footer.query_count)}, {layout.footer
.query_cache_count} cached) (CACHE: {layout.footer.cache_hits} hits,
{layout.footer.cache_misses} misses) ({layout.footer
.requests_per_second})
</p>
{#if layout.show_stop_impersonating}
<a
href={layout.stop_impersonating_path}
data-turbo-prefetch="false"
class="text-primary font-bold hover:text-red-300 transition-colors duration-200"
>Stop impersonating</a
>
{/if}
</div>
<div class="flex flex-row gap-2 mt-4 justify-center">
{#each layout.footer.active_users_graph as hour}
<div
class="bg-white opacity-10 grow max-w-1 rounded-sm"
title={hour.title}
style={`height: ${hour.height}px`}
></div>
{/each}
</div>
</footer>
</div>
</main>
{#if layout.currently_hacking}
<div
class="fixed top-0 right-5 max-w-sm max-h-[80vh] bg-dark border border-darkless rounded-b-xl shadow-lg z-1000 overflow-hidden transform transition-transform duration-300 ease-out"
>
<div
class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between"
onclick={toggleCurrentlyHacking}
>
<div class="text-white text-sm font-medium">
<div class="flex items-center">
<div
class="w-2 h-2 rounded-full bg-green-500 animate-pulse mr-2"
></div>
<span class="text-base">{countLabel()}</span>
</div>
</div>
</div>
{#if currentlyExpanded}
{#if layout.currently_hacking.users.length === 0}
<div class="p-4 bg-dark">
<div class="text-center text-muted text-sm italic">
No one is currently hacking :(
</div>
</div>
{:else}
<div
class="currently-hacking-list max-h-[60vh] max-w-100 overflow-y-auto p-2 bg-darker"
>
<div class="space-y-2">
{#each layout.currently_hacking.users as user}
<div
class="flex flex-col space-y-1 p-2 rounded-md hover:bg-dark transition-colors"
>
<div class="flex items-center gap-2">
{#if user.avatar_url}
<img
src={user.avatar_url}
alt={`${user.display_name || `User ${user.id}`}'s avatar`}
class="w-6 h-6 rounded-full aspect-square flex-shrink-0"
loading="lazy"
/>
{/if}
{#if user.slack_uid}
<a
href={`https://hackclub.slack.com/team/${user.slack_uid}`}
target="_blank"
class="text-blue-500 hover:underline text-sm"
>
@{user.display_name || `User ${user.id}`}
</a>
{:else}
<span class="text-white text-sm"
>{user.display_name || `User ${user.id}`}</span
>
{/if}
</div>
{#if user.active_project}
<div class="text-xs text-muted ml-8">
working on
{#if user.active_project.repo_url}
<a
href={user.active_project.repo_url}
target="_blank"
class="text-accent hover:text-cyan-400 transition-colors"
>
{user.active_project.name}
</a>
{:else}
{user.active_project.name}
{/if}
{#if visualizeGitUrl(user.active_project.repo_url)}
<a
href={visualizeGitUrl(user.active_project.repo_url)}
target="_blank"
class="ml-1">🌌</a
>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{/if}
</div>
{/if}
<div
class="fixed inset-0 flex items-center justify-center z-9999 transition-opacity duration-300 ease-in-out"
class:opacity-0={!logoutOpen}
class:pointer-events-none={!logoutOpen}
style="background-color: rgba(0, 0, 0, 0.5);backdrop-filter: blur(4px);"
onclick={(e) => e.target === e.currentTarget && closeLogout()}
>
<div
class={`bg-dark border border-primary rounded-lg p-6 max-w-md w-full mx-4 flex flex-col items-center justify-center transform transition-transform duration-300 ease-in-out ${logoutOpen ? "scale-100" : "scale-95"}`}
>
<div class="flex flex-col items-center w-full">
<div class="mb-4 flex justify-center w-full">
<svg
class="w-12 h-12 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"
/>
</svg>
</div>
<h3 class="text-2xl font-bold text-white mb-2 text-center w-full">
Woah hold on a sec
</h3>
<p class="text-gray-300 mb-6 text-center w-full">
You sure you want to log out? You can sign back in later but that is a
bit of a hassle...
</p>
<div class="flex w-full gap-3">
<div class="flex-1 min-w-0">
<button
type="button"
onclick={closeLogout}
class="w-full h-10 px-4 rounded-lg transition-colors duration-200 cursor-pointer m-0 bg-dark hover:bg-darkless border border-darkless text-gray-300"
>Go back</button
>
</div>
<div class="flex-1 min-w-0">
<form method="post" action={layout.signout_path} class="m-0">
<input
type="hidden"
name="authenticity_token"
value={layout.csrf_token}
/>
<input type="hidden" name="_method" value="delete" />
<button
type="submit"
class="w-full h-10 px-4 rounded-lg transition-colors duration-200 font-medium cursor-pointer m-0 bg-primary hover:bg-primary/75 text-white"
>Log out now</button
>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
:global(#app) {
display: flex;
flex: 1 1 auto;
min-height: 100vh;
width: 100%;
}
</style>

View file

@ -0,0 +1,136 @@
<script lang="ts">
let {
popular_editors,
all_editors,
}: {
popular_editors: [string, string][];
all_editors: [string, string][];
} = $props();
</script>
<svelte:head>
<title>Documentation - Hackatime</title>
<meta
name="description"
content="Complete documentation for Hackatime - learn how to track your coding time and use our API."
/>
</svelte:head>
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-surface-content mb-2">Documentation</h1>
<p class="text-muted">
Free, open-source time tracking for Hack Club. Like WakaTime, but free.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<a
href="/my/wakatime_setup"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<div class="text-2xl mb-2"></div>
<h3 class="font-semibold text-surface-content">Quick Start</h3>
<p class="text-sm text-muted text-center mt-1">
Set up in under a minute
</p>
</a>
<a
href="/docs/getting-started/installation"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<div class="text-2xl mb-2">💻</div>
<h3 class="font-semibold text-surface-content">Installation</h3>
<p class="text-sm text-muted text-center mt-1">Add to your editor</p>
</a>
<a
href="/api-docs"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<div class="text-2xl mb-2">📡</div>
<h3 class="font-semibold text-surface-content">API Docs</h3>
<p class="text-sm text-muted text-center mt-1">Interactive reference</p>
</a>
</div>
<h2 class="text-xl font-semibold text-surface-content mb-4">
Popular Editors
</h2>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3 mb-8">
{#each popular_editors as [name, slug]}
<a
href={`/docs/editors/${slug}`}
class="flex flex-col items-center p-3 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<img
src={`/images/editor-icons/${slug}-128.png`}
alt={name}
class="w-10 h-10 mb-2"
/>
<span class="text-sm text-surface-content">{name}</span>
</a>
{/each}
</div>
<details class="group">
<summary
class="flex items-center justify-between p-4 bg-surface border border-surface-200 rounded-lg cursor-pointer hover:border-surface-300 transition-colors select-none"
>
<span class="font-medium text-surface-content"
>All {all_editors.length} supported editors</span
>
<svg
class="w-5 h-5 text-muted group-open:rotate-180 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</summary>
<div
class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2 mt-3 p-4 bg-surface border border-surface-200 rounded-lg select-none"
>
{#each all_editors as [name, slug]}
<a
href={`/docs/editors/${slug}`}
class="flex flex-col items-center p-2 rounded hover:bg-surface-200 transition-colors"
>
<img
src={`/images/editor-icons/${slug}-128.png`}
alt={name}
class="w-8 h-8 mb-1"
/>
<span class="text-xs text-surface-content text-center leading-tight"
>{name}</span
>
</a>
{/each}
</div>
</details>
<div class="mt-8 p-4 bg-surface border border-surface-200 rounded-lg">
<p class="text-sm text-muted">
Need help? Ask in
<a
href="https://hackclub.slack.com/archives/C07MQ845X1F"
target="_blank"
class="text-primary hover:underline">#hackatime-v2</a
>
on Slack or
<a
href="https://github.com/hackclub/hackatime/issues"
target="_blank"
class="text-primary hover:underline">open an issue</a
>
on GitHub.
</p>
</div>
</div>

View file

@ -0,0 +1,119 @@
<script lang="ts">
import { inertia } from "@inertiajs/svelte";
let {
doc_path,
title,
rendered_content,
breadcrumbs,
edit_url,
meta,
}: {
doc_path: string;
title: string;
rendered_content: string;
breadcrumbs: { name: string; href: string | null; is_link: boolean }[];
edit_url: string;
meta: { description: string; keywords: string };
} = $props();
</script>
<svelte:head>
<title>{title} - Hackatime Documentation</title>
<meta name="description" content={meta.description} />
<meta name="keywords" content={meta.keywords} />
<meta property="og:title" content={`${title} - Hackatime Documentation`} />
<meta property="og:description" content={meta.description} />
<meta property="og:type" content="article" />
</svelte:head>
<div class="min-h-screen text-white">
<div class="max-w-8xl md:max-w-6xl mx-auto px-6 py-8">
<!-- Breadcrumbs -->
<nav class="mb-8">
{#each breadcrumbs as crumb, index}
{#if index === breadcrumbs.length - 1}
<span class="text-primary">{crumb.name}</span>
{:else}
{#if crumb.is_link && crumb.href}
<a
href={crumb.href}
use:inertia
class="text-secondary hover:text-primary">{crumb.name}</a
>
{:else}
<span class="text-secondary">{crumb.name}</span>
{/if}
<span class="text-secondary mx-2">/</span>
{/if}
{/each}
</nav>
<!-- Content -->
<div
class="bg-dark rounded-lg p-8 mb-8 prose prose-invert prose-lg max-w-none
prose-headings:text-primary prose-headings:font-bold prose-headings:leading-tight
prose-h1:text-4xl prose-h1:mb-6 prose-h1:text-primary prose-h1:mt-0
prose-h2:text-2xl prose-h2:mt-10 prose-h2:mb-4 prose-h2:text-primary prose-h2:border-b prose-h2:border-b-[#6e6468] prose-h2:pb-2
prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3 prose-h3:text-primary
prose-h4:text-lg prose-h4:mt-4 prose-h4:mb-2 prose-h4:text-white prose-h4:font-semibold
prose-p:text-white prose-p:leading-7 prose-p:mb-5
prose-a:text-primary prose-a:hover:text-red prose-a:underline prose-a:font-medium
prose-strong:text-white prose-strong:font-semibold
prose-em:text-secondary prose-em:italic
prose-code:bg-darkless prose-code:text-primary prose-code:px-2 prose-code:py-1 prose-code:rounded prose-code:text-sm prose-code:font-mono
prose-pre:bg-darkless prose-pre:border prose-pre:border-primary/20 prose-pre:rounded-lg prose-pre:p-4 prose-pre:overflow-x-auto
prose-pre:text-white prose-pre:text-sm
prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:bg-darkless prose-blockquote:pl-6 prose-blockquote:py-4 prose-blockquote:rounded-r-lg
prose-blockquote:text-secondary prose-blockquote:italic prose-blockquote:font-normal prose-blockquote:my-6
prose-ul:text-white prose-ul:mb-4 prose-ul:pl-6
prose-ol:text-white prose-ol:mb-4 prose-ol:pl-6
prose-li:text-white prose-li:mb-3 prose-li:leading-7 prose-li:pl-2
prose-table:border-collapse prose-table:border prose-table:border-primary/20 prose-table:rounded-lg prose-table:overflow-hidden prose-table:my-6
prose-th:bg-darkless prose-th:text-primary prose-th:font-semibold prose-th:p-3 prose-th:border prose-th:border-primary/20
prose-td:text-white prose-td:p-3 prose-td:border prose-td:border-primary/20
prose-img:rounded-lg prose-img:shadow-lg prose-img:mx-auto prose-img:block prose-img:max-w-24 prose-img:h-auto prose-img:my-4
prose-hr:border-primary/30 prose-hr:my-8
[&_ol>li::marker]:text-primary [&_ol>li::marker]:font-semibold
[&_ul>li::marker]:text-primary
[&_ol>li]:mb-3 [&_ol>li]:pl-2
[&_h2:not(:first-child)]:mt-10
[&_h3:not(:first-child)]:mt-8
[&_p_strong:first-child]:text-primary
[&_pre[class*='language-json']]:bg-darkless [&_pre[class*='language-json']]:border [&_pre[class*='language-json']]:border-primary/10
[&_pre[class*='language-bash']]:bg-darkless [&_pre[class*='language-bash']]:border [&_pre[class*='language-bash']]:border-primary/10
[&_img[alt*='PyCharm']]:w-16 [&_img[alt*='PyCharm']]:h-16 [&_img[alt*='PyCharm']]:mx-auto [&_img[alt*='PyCharm']]:block [&_img[alt*='PyCharm']]:my-4
[&_img[alt*='VS_Code']]:w-16 [&_img[alt*='VS_Code']]:h-16 [&_img[alt*='VS_Code']]:mx-auto [&_img[alt*='VS_Code']]:block [&_img[alt*='VS_Code']]:my-4
[&_img[alt*='IntelliJ']]:w-16 [&_img[alt*='IntelliJ']]:h-16 [&_img[alt*='IntelliJ']]:mx-auto [&_img[alt*='IntelliJ']]:block [&_img[alt*='IntelliJ']]:my-4
[&_img[src*='/images/editor-icons/']]:w-16 [&_img[src*='/images/editor-icons/']]:h-16 [&_img[src*='/images/editor-icons/']]:mx-auto [&_img[src*='/images/editor-icons/']]:block [&_img[src*='/images/editor-icons/']]:my-4
[&_.editor-steps]:bg-darkless [&_.editor-steps]:p-6 [&_.editor-steps]:rounded-lg [&_.editor-steps]:my-4
[&_.editor-steps_ol]:m-0
[&_.editor-steps_li]:mb-2"
>
{@html rendered_content}
</div>
<!-- Edit on GitHub -->
<div
class="flex items-center justify-center gap-2 py-6 text-sm text-secondary/70"
>
<span>Found an issue with this page?</span>
<a
href={edit_url}
target="_blank"
class="inline-flex items-center gap-1 text-primary hover:text-red transition-colors font-medium"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
viewBox="0 0 16 16"
fill="currentColor"
><path
d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"
/></svg
>
Edit on GitHub
</a>
</div>
</div>
</div>

View file

@ -0,0 +1,31 @@
<script lang="ts">
let {
status_code = 404,
title = "Page Not Found",
message = "The page you were looking for doesn't exist.",
}: {
status_code?: number;
title?: string;
message?: string;
} = $props();
</script>
<svelte:head>
<title>{title} - Hackatime</title>
</svelte:head>
<div class="min-h-screen text-white flex items-center justify-center">
<div class="max-w-xl mx-auto px-6 py-8 text-center">
<div class="bg-dark rounded-lg p-8">
<h1 class="text-6xl font-bold text-primary mb-4">{status_code}</h1>
<h2 class="text-2xl font-semibold text-white mb-4">{title}</h2>
<p class="text-secondary mb-8">{message}</p>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 rounded-lg bg-primary text-white font-medium hover:brightness-110 transition-all"
>
Go Home
</a>
</div>
</div>
</div>

View file

@ -0,0 +1,80 @@
<script lang="ts">
const EXTENSIONS = [
{
name: "Hackatime Desktop",
source: "https://github.com/hackclub/hackatime-desktop",
description:
"Desktop app for Hackatime. Runs on Mac, Windows, and Linux.",
install: "https://github.com/hackclub/hackatime-desktop/releases",
},
{
name: "Cattatime",
source: "https://github.com/joysudo/catatime/tree/master",
description:
"A Tamagotchi system. Code, fill your cup, and get your pet rewards.",
install: "https://github.com/joysudo/catatime/releases/",
},
];
</script>
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-surface-content mb-2">Extensions</h1>
<p class="text-muted">
Third-party tools created by the community.
<span class="text-primary opacity-80 italic text-sm ml-1">
* Not guaranteed to work.
</span>
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#each EXTENSIONS as extension}
<div
class="flex flex-col bg-surface border border-surface-200 rounded-lg p-5 hover:border-surface-300 transition-colors"
>
<div class="flex justify-between items-start mb-1">
<h2 class="text-xl font-bold text-surface-content">
{extension.name}
</h2>
</div>
<p class="text-muted text-sm leading-relaxed mb-6 flex-1">
{extension.description}
</p>
<div class="grid grid-cols-2 gap-3 mt-auto">
<a
href={extension.install}
target="_blank"
class="flex items-center justify-center gap-2 px-4 py-2 rounded bg-primary text-white font-medium text-sm hover:opacity-90 transition-opacity"
>
<span>Install</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline
points="7 10 12 15 17 10"
/><line x1="12" x2="12" y1="15" y2="3" /></svg
>
</a>
<a
href={extension.source}
target="_blank"
class="flex items-center justify-center gap-2 px-4 py-2 rounded bg-surface-200 text-surface-content font-medium text-sm hover:bg-surface-300 transition-colors"
>
Source
</a>
</div>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,125 @@
<script lang="ts">
import type {
ActivityGraphData,
} from "../../types/index";
import BanNotice from "./signedIn/BanNotice.svelte";
import GitHubLinkBanner from "./signedIn/GitHubLinkBanner.svelte";
import SetupNotice from "./signedIn/SetupNotice.svelte";
import TodaySentence from "./signedIn/TodaySentence.svelte";
import Dashboard from "./signedIn/Dashboard.svelte";
import ActivityGraph from "./signedIn/ActivityGraph.svelte";
type SocialProofUser = { display_name: string; avatar_url: string };
type FilterableDashboardData = {
total_time: number;
total_heartbeats: number;
top_project: string | null;
top_language: string | null;
top_editor: string | null;
top_operating_system: string | null;
project_durations: Record<string, number>;
language_stats: Record<string, number>;
editor_stats: Record<string, number>;
operating_system_stats: Record<string, number>;
category_stats: Record<string, number>;
weekly_project_stats: Record<string, Record<string, number>>;
project: string[];
language: string[];
editor: string[];
operating_system: string[];
category: string[];
selected_interval: string;
selected_from: string;
selected_to: string;
selected_project: string[];
selected_language: string[];
selected_editor: string[];
selected_operating_system: string[];
selected_category: string[];
};
let {
flavor_text,
trust_level_red,
show_wakatime_setup_notice,
ssp_message,
ssp_users_recent,
ssp_users_size,
github_uid_blank,
github_auth_path,
wakatime_setup_path,
show_logged_time_sentence,
todays_duration_display,
todays_languages,
todays_editors,
filterable_dashboard_data,
activity_graph,
}: {
flavor_text: string;
trust_level_red: boolean;
show_wakatime_setup_notice: boolean;
ssp_message?: string | null;
ssp_users_recent: SocialProofUser[];
ssp_users_size: number;
github_uid_blank: boolean;
github_auth_path: string;
wakatime_setup_path: string;
show_logged_time_sentence: boolean;
todays_duration_display: string;
todays_languages: string[];
todays_editors: string[];
filterable_dashboard_data: FilterableDashboardData;
activity_graph: ActivityGraphData;
} = $props();
</script>
<div>
<!-- Header Section -->
<div class="mb-8">
<div class="flex items-center space-x-2">
<p class="italic text-gray-400 m-0">
{@html flavor_text}
</p>
</div>
<h1 class="font-bold mt-2 mb-4 text-3xl md:text-4xl">
Keep Track of <span class="text-primary">Your</span> Coding Time
</h1>
</div>
{#if trust_level_red}
<BanNotice />
{/if}
{#if show_wakatime_setup_notice}
<SetupNotice
{wakatime_setup_path}
{ssp_message}
{ssp_users_recent}
{ssp_users_size}
/>
{/if}
{#if github_uid_blank}
<GitHubLinkBanner {github_auth_path} />
{/if}
<div class="flex flex-col gap-8">
<!-- Today Stats & Leaderboard -->
<div>
<TodaySentence
{show_logged_time_sentence}
{todays_duration_display}
{todays_languages}
{todays_editors}
/>
</div>
<!-- Main Dashboard -->
<Dashboard data={filterable_dashboard_data} />
<!-- Activity Graph -->
<ActivityGraph data={activity_graph} />
</div>
</div>

View file

@ -0,0 +1,261 @@
<script lang="ts">
type HomeStats = { seconds_tracked?: number; users_tracked?: number };
let {
hca_auth_path,
slack_auth_path,
email_auth_path,
sign_in_email,
show_dev_tool,
dev_magic_link,
csrf_token,
home_stats,
}: {
hca_auth_path: string;
slack_auth_path: string;
email_auth_path: string;
sign_in_email: boolean;
show_dev_tool: boolean;
dev_magic_link?: string | null;
csrf_token: string;
home_stats: HomeStats;
} = $props();
let isSigningIn = $state(false);
const editors = [
{ name: "VS Code", slug: "vs-code" },
{ name: "PyCharm", slug: "pycharm" },
{ name: "IntelliJ", slug: "intellij-idea" },
{ name: "Vim", slug: "vim" },
{ name: "Neovim", slug: "neovim" },
{ name: "Zed", slug: "zed" },
{ name: "Cursor", slug: "cursor" },
{ name: "Terminal", slug: "terminal" },
];
const numberFormatter = new Intl.NumberFormat("en-US");
const pluralize = (count: number, singular: string, plural: string) =>
count === 1 ? singular : plural;
const formatNumber = (value: number) => numberFormatter.format(value);
const hoursTracked = $derived(
home_stats?.seconds_tracked
? Math.floor(home_stats.seconds_tracked / 3600)
: 0,
);
const usersTracked = $derived(home_stats?.users_tracked ?? 0);
// Grid background pattern
const gridPattern = `background-image: linear-gradient(to right, #4A2D3133 1px, transparent 1px), linear-gradient(to bottom, #4A2D3133 1px, transparent 1px); background-size: 6rem 6rem;`;
</script>
<div
class="min-h-screen bg-darker text-white font-sans selection:bg-primary selection:text-white relative overflow-hidden flex flex-col"
>
<!-- Decorative Grid Background -->
<div
class="absolute inset-0 pointer-events-none opacity-60"
style={gridPattern}
></div>
<!-- Navigation -->
<nav
class="relative z-10 w-full max-w-7xl mx-auto px-6 py-6 flex justify-between items-center"
>
<div class="flex items-center gap-2 min-w-[140px]">
<img src="/images/icon-rounded.png" class="h-8 w-8" alt="Logo" />
<span class="font-bold tracking-tight text-lg">Hackatime</span>
</div>
<div class="hidden md:flex gap-8 text-sm font-medium text-text-muted">
<a href="#stats" class="hover:text-white transition-colors">Stats</a>
<a href="#editors" class="hover:text-white transition-colors">Editors</a>
<a href="/docs" class="hover:text-white transition-colors">Developers</a>
</div>
<div class="min-w-[140px] flex justify-end">
<a
href={hca_auth_path}
class="text-sm font-bold border border-primary text-primary px-4 py-2 rounded-lg hover:bg-primary hover:text-white transition-all"
>
Login
</a>
</div>
</nav>
<!-- Main Content -->
<main
class="relative z-10 flex-1 flex flex-col items-center justify-center w-full max-w-4xl mx-auto px-4 pt-10 pb-20"
>
<!-- Hero Text -->
<div class="text-center mb-10 mt-4 space-y-4">
<h1 class="text-5xl font-serif tracking-tight leading-[1.1]">
The free and <br />
<span class="italic text-primary">open-source</span> coding time tracker.
</h1>
<p class="text-secondary max-w-xl mx-auto text-lg leading-relaxed">
Code stats, straight from your code editors. That's it!
</p>
</div>
<!-- Auth Section -->
<div class="w-full max-w-md space-y-4">
{#if sign_in_email}
<div
class="bg-dark rounded-2xl border border-darkless p-8 text-center space-y-2"
>
<p class="text-white font-medium">Check your email!</p>
<p class="text-secondary text-sm">
We sent a sign-in link to your inbox. Check your spam if you can't
see it!
</p>
{#if show_dev_tool && dev_magic_link}
<a
href={dev_magic_link}
class="text-xs text-secondary underline hover:text-white"
>Dev: Open Link</a
>
{/if}
</div>
{:else}
<!-- Primary Auth Buttons -->
<a
href={hca_auth_path}
onclick={() => (isSigningIn = true)}
class="w-full flex items-center justify-center gap-3 px-6 py-3.5 rounded-xl bg-primary text-white font-medium hover:brightness-110 transition-all"
>
{#if isSigningIn}
<svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
{:else}
<img
src="/images/icon-rounded.png"
class="h-5 w-5"
alt="Hack Club"
/>
{/if}
<span>Sign in with Hack Club</span>
</a>
<a
href={slack_auth_path}
class="w-full flex items-center justify-center gap-3 px-6 py-3.5 rounded-xl bg-dark border border-darkless text-white font-medium hover:bg-darkless transition-all"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"
><path
d="M6 15a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2h2zm1 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2a2 2 0 0 1-2-2zm2-8a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2v2zm0 1a2 2 0 0 1 2 2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2a2 2 0 0 1 2-2zm8 2a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2h-2zm-1 0a2 2 0 0 1-2 2a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2a2 2 0 0 1 2 2zm-2 8a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2v-2zm0-1a2 2 0 0 1-2-2a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2a2 2 0 0 1-2 2z"
/></svg
>
<span>Sign in with Slack</span>
</a>
<!-- Divider -->
<div class="flex items-center gap-4 py-1">
<div class="flex-1 h-px bg-darkless"></div>
<span class="text-xs text-secondary/60 uppercase tracking-wider"
>or</span
>
<div class="flex-1 h-px bg-darkless"></div>
</div>
<!-- Email Form -->
<form method="post" action={email_auth_path} data-turbo="false">
<input type="hidden" name="authenticity_token" value={csrf_token} />
<div class="flex gap-2">
<input
type="email"
name="email"
placeholder="you@email.com"
required
class="flex-1 bg-dark text-white placeholder-secondary/40 rounded-xl py-3.5 px-4 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all border border-darkless focus:border-primary text-sm"
/>
<button
type="submit"
class="px-5 py-3.5 bg-dark border border-primary text-white rounded-xl hover:bg-primary transition-all text-sm font-medium"
>
Send link
</button>
</div>
</form>
{/if}
</div>
<!-- Stats / Feature Pills -->
<div class="mt-8 flex flex-wrap justify-center gap-3" id="stats">
{#if home_stats?.seconds_tracked}
<div
class="px-4 py-2 bg-dark border border-darkless rounded-lg shadow-sm"
>
<span class="text-sm font-medium text-white"
>{formatNumber(hoursTracked)} hours tracked</span
>
</div>
<div
class="px-4 py-2 bg-dark border border-darkless rounded-lg shadow-sm"
>
<span class="text-sm font-medium text-white"
>{formatNumber(usersTracked)} hackers</span
>
</div>
{/if}
<div
class="px-4 py-2 bg-dark border border-darkless rounded-lg shadow-sm"
>
<span class="text-sm font-medium text-white">Works offline</span>
</div>
<div
class="px-4 py-2 bg-dark border border-darkless rounded-lg shadow-sm"
>
<span class="text-sm font-medium text-white">100% free</span>
</div>
</div>
<!-- Editor Logos -->
<div class="mt-20 text-center w-full" id="editors">
<h3 class="text-xl font-serif mb-8 text-secondary">
Compatible with all your favourite editors
</h3>
<div
class="grid grid-cols-4 md:grid-cols-8 gap-8 items-center justify-items-center opacity-60 hover:opacity-100 transition-all duration-500"
>
{#each editors as editor}
<a
href={`/docs/editors/${editor.slug}`}
class="group flex flex-col items-center gap-2 hover:-translate-y-1 transition-transform"
>
<img
src={`/images/editor-icons/${editor.slug}-128.png`}
alt={editor.name}
class="w-8 h-8 object-contain"
/>
<span
class="text-[10px] uppercase tracking-wider opacity-0 group-hover:opacity-100 transition-opacity absolute -bottom-5 text-secondary"
>{editor.name}</span
>
</a>
{/each}
</div>
<div class="mt-8 text-sm text-secondary/60">
+ 70 more supported editors
</div>
</div>
</main>
</div>
<style>
/* Custom Scrollbar for cleaner look if needed */
:global(body) {
background-color: #1f1617;
}
</style>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import type { ActivityGraphData } from "../../../types/index";
let { data }: { data: ActivityGraphData } = $props();
function buildDateRange(startStr: string, endStr: string): string[] {
const dates: string[] = [];
const current = new Date(startStr + "T00:00:00");
const end = new Date(endStr + "T00:00:00");
while (current <= end) {
dates.push(current.toISOString().slice(0, 10));
current.setDate(current.getDate() + 1);
}
return dates;
}
function bgColor(seconds: number, busiestDaySeconds: number): string {
if (seconds < 60) return "bg-[#151b23]";
const ratio = seconds / busiestDaySeconds;
if (ratio >= 0.8) return "bg-[#56d364]";
if (ratio >= 0.5) return "bg-[#2ea043]";
if (ratio >= 0.2) return "bg-[#196c2e]";
return "bg-[#033a16]";
}
function durationInWords(seconds: number): string {
if (seconds < 60) return "less than a minute";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `about ${hours} ${hours === 1 ? "hour" : "hours"}`;
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
}
const dates = $derived(buildDateRange(data.start_date, data.end_date));
</script>
<div class="w-full overflow-x-auto mt-6 pb-2.5">
<div class="grid grid-rows-7 grid-flow-col gap-1 w-full lg:w-1/2">
{#each dates as date}
{@const seconds = data.duration_by_date[date] ?? 0}
<a
class="day transition-all duration-75 w-3 h-3 rounded-sm hover:scale-110 hover:z-10 hover:shadow-md {bgColor(
seconds,
data.busiest_day_seconds,
)}"
href="?date={date}"
title="you hacked for {durationInWords(seconds)} on {date}"
data-date={date}
data-duration={durationInWords(seconds)}
>
&nbsp;
</a>
{/each}
</div>
<p class="super">
Calculated in
<a href={data.timezone_settings_path}>{data.timezone_label}</a>
</p>
</div>

View file

@ -0,0 +1,14 @@
<script lang="ts">
const squares = Array.from({ length: 365 });
</script>
<div class="w-full overflow-x-auto mt-6 pb-2.5 animate-pulse">
<div class="grid grid-rows-7 grid-flow-col gap-1 w-full lg:w-1/2">
{#each squares as _}
<div class="w-3 h-3 rounded-sm bg-darkless opacity-50"></div>
{/each}
</div>
<p class="super mt-2">
<span class="h-3 w-48 bg-darkless rounded inline-block"></span>
</p>
</div>

View file

@ -0,0 +1,11 @@
<div class="text-primary bg-red-500/10 border-2 border-red-500/20 p-4 text-center rounded-lg mb-4">
<div class="flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 14.5a6.5 6.5 0 1 0 0-13a6.5 6.5 0 0 0 0 13M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m1-5a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0z" clip-rule="evenodd" /></svg>
<span class="text-3xl font-bold block ml-2">Hold up! Your account has been banned for suspicious activity.</span>
</div>
<div>
<p class="text-primary text-left text-lg mb-2"><b>What does this mean?</b> Your account was convicted for fraud or abuse of Hackatime, such as using methods to gain an unfair advantage on the leaderboards or attempting to manipulate your coding time in any way. This restricts your access to participate in public leaderboards, but Hackatime will still track and display your time. This may also affect your ability to participate in current and future Hack Club events.</p>
<p class="text-primary text-left text-lg mb-2"><b>What can I do?</b> Account bans are non-negotiable, and will not be removed unless determined to have been issued incorrectly. In that case, it will automatically be removed. We take fraud very seriously and have a zero-tolerance policy for abuse. If you believe this was a mistake, please DM the <a href="https://hackclub.slack.com/team/U091HC53CE8" target="_blank" class="underline">Fraud Department</a> on Slack. We do not respond in any other channel, DM or thread.</p>
<p class="text-primary text-left text-lg mb-0"><b>Can I know what caused this?</b> No. We do not disclose the patterns that were detected. Releasing this information would only benefit fraudsters. The fraud team regularly investigates claims of false bans to increase the effectiveness of our detection systems to combat fraud.</p>
</div>
</div>

View file

@ -0,0 +1,183 @@
<script lang="ts">
import { router } from "@inertiajs/svelte";
import { secondsToDisplay } from "./utils";
import StatCard from "./StatCard.svelte";
import HorizontalBarList from "./HorizontalBarList.svelte";
import PieChart from "./PieChart.svelte";
import ProjectTimelineChart from "./ProjectTimelineChart.svelte";
import IntervalSelect from "./IntervalSelect.svelte";
import MultiSelect from "./MultiSelect.svelte";
let {
data,
}: {
data: Record<string, any>;
} = $props();
let loading = $state(false);
const langStats = $derived(
(data.language_stats || {}) as Record<string, number>,
);
const editorStats = $derived(
(data.editor_stats || {}) as Record<string, number>,
);
const osStats = $derived(
(data.operating_system_stats || {}) as Record<string, number>,
);
const projectEntries = $derived(
Object.entries(data.project_durations || {}) as [string, number][],
);
const weeklyStats = $derived(
(data.weekly_project_stats || {}) as Record<string, Record<string, number>>,
);
const capitalize = (s: string) =>
s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
function applyFilters(overrides: Record<string, string>) {
const current = new URL(window.location.href);
for (const [k, v] of Object.entries(overrides)) {
if (v) {
current.searchParams.set(k, v);
} else {
current.searchParams.delete(k);
}
}
loading = true;
router.get(
current.pathname + current.search,
{},
{
preserveState: true,
preserveScroll: true,
only: ["filterable_dashboard_data"],
onFinish: () => (loading = false),
},
);
}
function onIntervalChange(interval: string, from: string, to: string) {
if (from || to) {
applyFilters({ interval: "custom", from, to });
} else {
applyFilters({ interval, from: "", to: "" });
}
}
function onFilterChange(param: string, selected: string[]) {
applyFilters({ [param]: selected.join(",") });
}
</script>
<div class="flex flex-col gap-6 w-full" class:opacity-60={loading}>
<!-- Filters -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-2">
<IntervalSelect
selected={data.selected_interval || ""}
from={data.selected_from || ""}
to={data.selected_to || ""}
onchange={onIntervalChange}
/>
<MultiSelect
label="Project"
param="project"
values={data.project || []}
selected={data.selected_project || []}
onchange={(s) => onFilterChange("project", s)}
/>
<MultiSelect
label="Language"
param="language"
values={data.language || []}
selected={data.selected_language || []}
onchange={(s) => onFilterChange("language", s)}
/>
<MultiSelect
label="OS"
param="operating_system"
values={data.operating_system || []}
selected={data.selected_operating_system || []}
onchange={(s) => onFilterChange("operating_system", s)}
/>
<MultiSelect
label="Editor"
param="editor"
values={data.editor || []}
selected={data.selected_editor || []}
onchange={(s) => onFilterChange("editor", s)}
/>
<MultiSelect
label="Category"
param="category"
values={data.category || []}
selected={data.selected_category || []}
onchange={(s) => onFilterChange("category", s)}
/>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard
label="Total Time"
value={secondsToDisplay(data.total_time)}
highlight
/>
<StatCard
label="Top Project"
value={data.top_project || "None"}
subtitle={data.singular_project ? "obviously" : ""}
/>
<StatCard
label="Top Language"
value={data.top_language || "Unknown"}
subtitle={data.singular_language ? "obviously" : ""}
/>
<StatCard
label="Top OS"
value={data.top_operating_system || "Unknown"}
subtitle={data.singular_operating_system ? "obviously" : ""}
/>
<StatCard
label="Top Editor"
value={data.top_editor || "Unknown"}
subtitle={data.singular_editor ? "obviously" : ""}
/>
<StatCard
label="Top Category"
value={capitalize(data.top_category) || "Unknown"}
subtitle={data.singular_category ? "obviously" : ""}
/>
</div>
<!-- Charts Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full">
{#if projectEntries.length > 1}
<div class="lg:col-span-1">
<HorizontalBarList
title="Project Durations"
entries={projectEntries}
empty_message="No data yet."
useLogScale
/>
</div>
{/if}
{#if Object.keys(langStats).length > 0}
<PieChart title="Languages" stats={langStats} />
{/if}
{#if Object.keys(editorStats).length > 0}
<PieChart title="Editors" stats={editorStats} />
{/if}
{#if Object.keys(osStats).length > 0}
<PieChart title="Operating Systems" stats={osStats} />
{/if}
<div class="lg:col-span-2">
<ProjectTimelineChart {weeklyStats} />
</div>
</div>
</div>

View file

@ -0,0 +1,14 @@
<script lang="ts">
const filterRows = Array.from({ length: 6 });
</script>
<div class="max-w-6xl mx-auto my-0 animate-pulse">
<div class="flex gap-4 mt-2 mb-6 flex-wrap">
{#each filterRows as _}
<div class="filter flex-1 min-w-37.5 relative">
<div class="h-3 w-16 bg-darkless rounded mb-1.5"></div>
<div class="h-10 w-full bg-darkless rounded-lg"></div>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
let { github_auth_path }: { github_auth_path: string } = $props();
</script>
<div class="bg-dark border border-primary rounded-lg p-4 mb-6">
<div class="flex items-center gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
class="text-white shrink-0"
><path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
/></svg
>
<div class="flex-1">
<span class="text-white"
>Link your GitHub account to unlock project linking, show what
you're working on, and qualify for leaderboards!</span
>
</div>
<a
href={github_auth_path}
class="bg-primary hover:bg-primary text-white font-medium px-4 py-2 rounded-lg transition-colors duration-200 shrink-0"
data-turbo="false">Connect GitHub</a
>
</div>
</div>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { secondsToDisplay, percentOf, logScale } from "./utils";
let {
title,
entries,
empty_message = "No data yet.",
useLogScale = false,
}: {
title: string;
entries: [string, number][];
empty_message?: string;
useLogScale?: boolean;
} = $props();
const maxVal = $derived(Math.max(...entries.map(([_, v]) => v || 0), 1));
const barWidth = (seconds: number) =>
useLogScale ? logScale(seconds, maxVal) : percentOf(seconds, maxVal);
</script>
<div
class="bg-dark/50 border border-white/10 rounded-xl p-6 flex flex-col h-full"
>
<h3 class="text-lg font-semibold mb-4 text-white/90">{title}</h3>
{#if entries.length > 0}
<div class="space-y-2.5 overflow-y-auto flex-1 pr-2 custom-scrollbar">
{#each entries as [label, seconds]}
<div class="flex items-center gap-4 group">
<div
class="w-1/3 truncate font-medium text-sm text-gray-300 text-right group-hover:text-white transition-colors"
title={label}
>
{label}
</div>
<div class="flex-1 relative">
<div
class="bg-primary rounded-md h-6 flex items-center justify-end px-3 transition-all duration-500 ease-out"
style={`width:${Math.max(barWidth(seconds), 15)}%`}
>
<span class="text-xs font-mono text-white whitespace-nowrap">
{secondsToDisplay(seconds)}
</span>
</div>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-muted text-sm italic">{empty_message}</p>
{/if}
</div>

View file

@ -0,0 +1,156 @@
<script lang="ts">
const INTERVALS = [
{ key: "today", label: "Today" },
{ key: "yesterday", label: "Yesterday" },
{ key: "this_week", label: "This Week" },
{ key: "last_7_days", label: "Last 7 Days" },
{ key: "this_month", label: "This Month" },
{ key: "last_30_days", label: "Last 30 Days" },
{ key: "this_year", label: "This Year" },
{ key: "last_12_months", label: "Last 12 Months" },
{ key: "flavortown", label: "Flavortown" },
{ key: "summer_of_making", label: "Summer of Making" },
{ key: "high_seas", label: "High Seas" },
{ key: "low_skies", label: "Low Skies" },
{ key: "scrapyard", label: "Scrapyard Global" },
{ key: "", label: "All Time" },
] as const;
let {
selected,
from,
to,
onchange,
}: {
selected: string;
from: string;
to: string;
onchange: (interval: string, from: string, to: string) => void;
} = $props();
let open = $state(false);
let customFrom = $state("");
let customTo = $state("");
let container: HTMLDivElement | undefined = $state();
$effect(() => {
customFrom = from;
customTo = to;
});
const displayLabel = $derived.by(() => {
if (selected && selected !== "custom") {
return INTERVALS.find((i) => i.key === selected)?.label ?? selected;
}
if (from && to) return `${from} to ${to}`;
if (from) return `From ${from}`;
if (to) return `Until ${to}`;
return "All Time";
});
const isDefault = $derived(!selected && !from && !to);
function handleClickOutside(e: MouseEvent) {
if (container && !container.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
document.addEventListener("click", handleClickOutside, true);
return () => document.removeEventListener("click", handleClickOutside, true);
}
});
function selectInterval(key: string) {
onchange(key, "", "");
open = false;
}
function applyCustomRange() {
onchange("", customFrom, customTo);
open = false;
}
function clear() {
onchange("", "", "");
open = false;
}
</script>
<div class="filter relative" bind:this={container}>
<span class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider">
Date Range
</span>
<div class="group flex items-center border border-white/10 rounded-lg bg-darkless m-0 p-0 transition-all duration-200 hover:border-white/20">
<button
type="button"
class="flex-1 px-3 py-2.5 text-sm cursor-pointer select-none text-gray-300 m-0 bg-transparent flex items-center justify-between border-0"
onclick={() => (open = !open)}
>
<span>{displayLabel}</span>
<svg class="w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
{#if !isDefault}
<button
type="button"
class="px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-white/10 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150"
onclick={clear}
>
</button>
{/if}
</div>
{#if open}
<div class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-white/10 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2">
<div class="overflow-y-auto m-0 max-h-56">
{#each INTERVALS as interval}
<label class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-gray-300 m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150">
<input
type="radio"
name="interval"
class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-white/20 rounded-full bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-white/40 transition-colors duration-150"
checked={selected === interval.key && !from && !to}
onchange={() => selectInterval(interval.key)}
/>
{interval.label}
</label>
{/each}
</div>
<hr class="my-2 border-white/10" />
<div class="flex flex-col gap-2.5 pt-1">
<label class="flex items-center justify-between text-sm text-gray-300">
<span class="text-secondary/80">Start</span>
<input
type="date"
class="ml-2 py-2 px-3 bg-dark border border-white/10 rounded-md text-sm text-gray-200 focus:outline-none focus:border-white/20 transition-colors duration-150"
bind:value={customFrom}
/>
</label>
<label class="flex items-center justify-between text-sm text-gray-300">
<span class="text-secondary/80">End</span>
<input
type="date"
class="ml-2 py-2 px-3 bg-dark border border-white/10 rounded-md text-sm text-gray-200 focus:outline-none focus:border-white/20 transition-colors duration-150"
bind:value={customTo}
/>
</label>
<button
type="button"
class="px-3 py-2.5 mt-1 rounded-md font-medium text-sm transition-all duration-200 cursor-pointer bg-primary text-white hover:bg-primary/90 border-0"
onclick={applyCustomRange}
>
Apply
</button>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,131 @@
<script lang="ts">
let {
label,
param,
values,
selected,
onchange,
}: {
label: string;
param: string;
values: string[];
selected: string[];
onchange: (selected: string[]) => void;
} = $props();
let open = $state(false);
let search = $state("");
let container: HTMLDivElement | undefined = $state();
let filtered = $derived(
search
? values.filter((v) => v.toLowerCase().includes(search.toLowerCase()))
: values,
);
let displayText = $derived(
selected.length === 0
? `Filter by ${label}...`
: selected.length === 1
? selected[0]
: `${selected.length} selected`,
);
function toggle(value: string) {
if (selected.includes(value)) {
onchange(selected.filter((s) => s !== value));
} else {
onchange([...selected, value]);
}
}
function clear(e: MouseEvent) {
e.stopPropagation();
onchange([]);
}
function handleClickOutside(e: MouseEvent) {
if (container && !container.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
document.addEventListener("click", handleClickOutside, true);
return () =>
document.removeEventListener("click", handleClickOutside, true);
}
});
$effect(() => {
if (!open) {
search = "";
}
});
</script>
<div class="filter relative" bind:this={container}>
<span class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider">
{label}
</span>
<div class="group flex items-center border border-white/10 rounded-lg bg-darkless m-0 p-0 transition-all duration-200 hover:border-white/20">
<button
type="button"
class="flex-1 px-3 py-2.5 text-sm cursor-pointer select-none text-gray-300 m-0 bg-transparent flex items-center justify-between border-0 min-w-0"
onclick={() => (open = !open)}
>
<span class="truncate {selected.length === 0 ? 'text-secondary/60' : ''}">
{displayText}
</span>
<svg
class={`w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if selected.length > 0}
<button
type="button"
class="px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-white/10 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150"
onclick={clear}
>
×
</button>
{/if}
</div>
{#if open}
<div class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-white/10 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2">
<input
type="text"
placeholder="Search..."
class="w-full border border-white/10 px-3 py-2.5 mb-2 bg-dark text-white text-sm rounded-md h-auto placeholder:text-secondary/60 focus:outline-none focus:border-white/20"
bind:value={search}
/>
<div class="overflow-y-auto m-0 max-h-64">
{#each filtered as value}
<label class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-gray-300 m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150">
<input
type="checkbox"
checked={selected.includes(value)}
onchange={() => toggle(value)}
class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-white/20 rounded bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-white/40 transition-colors duration-150"
/>
{value}
</label>
{/each}
{#if filtered.length === 0}
<div class="px-3 py-2.5 text-sm text-secondary/60">No results</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { PieChart } from "layerchart";
let {
title,
stats,
}: {
title: string;
stats: Record<string, number>;
} = $props();
const PIE_COLORS = [
"#60a5fa",
"#f472b6",
"#fb923c",
"#facc15",
"#4ade80",
"#2dd4bf",
"#a78bfa",
"#f87171",
"#38bdf8",
"#e879f9",
"#34d399",
"#fbbf24",
"#818cf8",
"#fb7185",
"#22d3ee",
"#a3e635",
"#c084fc",
"#f97316",
"#14b8a6",
"#8b5cf6",
"#ec4899",
"#84cc16",
"#06b6d4",
"#d946ef",
"#10b981",
];
const data = $derived(
Object.entries(stats).map(([name, value]) => ({ name, value })),
);
const legendClasses = {
root: "w-full px-2",
swatches: "flex-wrap justify-center",
label: "text-xs text-white/70",
};
const legendPadding = $derived.by(() => {
const rows = Math.max(1, Math.ceil(data.length / 4));
return Math.min(96, 24 + rows * 18);
});
</script>
<div
class="bg-dark/50 border border-white/10 rounded-xl p-6 flex flex-col h-full"
>
<h2 class="mb-4 text-lg font-semibold text-white/90">{title}</h2>
<div class="h-[260px] sm:h-[280px] lg:h-[300px]">
{#if data.length > 0}
<PieChart
{data}
key="name"
value="value"
cRange={PIE_COLORS}
legend={true}
padding={{ bottom: legendPadding }}
props={{
legend: { classes: legendClasses },
}}
/>
{/if}
</div>
</div>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { secondsToDisplay, percentOf } from "./utils";
let {
entries,
}: {
entries: [string, Record<string, number>][];
} = $props();
</script>
<div
class="bg-dark border border-primary rounded-xl p-6 flex flex-col md:col-span-2"
>
<h3 class="text-xl font-semibold mb-4">Project Timeline</h3>
{#if entries.length > 0}
<div class="flex flex-col gap-2 max-h-96 overflow-y-auto">
{#each entries as [week, stats]}
{@const total = Object.values(stats).reduce(
(a, v) => a + (v || 0),
0,
)}
<div class="flex items-center gap-3">
<div class="w-28 text-sm text-muted">{week}</div>
<div class="flex-1 bg-darkless rounded h-3 overflow-hidden">
<div class="flex h-3 w-full">
{#each Object.entries(stats) as [project, seconds]}
<div
class="h-3 bg-primary/80"
style={`width:${percentOf(seconds, total)}%`}
title={`${project}: ${secondsToDisplay(seconds)}`}
></div>
{/each}
</div>
</div>
<div class="w-16 text-sm text-muted text-right">
{secondsToDisplay(total)}
</div>
</div>
{/each}
</div>
{:else}
<p class="text-muted">No timeline data.</p>
{/if}
</div>

View file

@ -0,0 +1,108 @@
<script lang="ts">
import { BarChart } from "layerchart";
let {
weeklyStats,
}: {
weeklyStats: Record<string, Record<string, number>>;
} = $props();
const PIE_COLORS = [
"#60a5fa", "#f472b6", "#fb923c", "#facc15", "#4ade80",
"#2dd4bf", "#a78bfa", "#f87171", "#38bdf8", "#e879f9",
"#34d399", "#fbbf24", "#818cf8", "#fb7185", "#22d3ee",
"#a3e635", "#c084fc", "#f97316", "#14b8a6", "#8b5cf6",
];
const sortedWeeks = $derived(Object.keys(weeklyStats).sort());
const allProjects = $derived.by(() => {
const projectTotals = new Map<string, number>();
for (const weekData of Object.values(weeklyStats)) {
for (const [project, seconds] of Object.entries(weekData)) {
projectTotals.set(project, (projectTotals.get(project) || 0) + seconds);
}
}
return Array.from(projectTotals.entries())
.sort((a, b) => b[1] - a[1])
.map(([name]) => name);
});
const data = $derived(
sortedWeeks.map((week) => {
const row: Record<string, string | number> = {
week: new Date(week).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
};
for (const project of allProjects) {
row[project] = weeklyStats[week][project] || 0;
}
return row;
}),
);
const series = $derived(
allProjects.map((project, i) => ({
key: project,
label: project,
color: PIE_COLORS[i % PIE_COLORS.length],
})),
);
const legendClasses = {
root: "w-full px-2",
swatches: "flex-wrap justify-center",
label: "text-xs text-white/70",
};
const legendPadding = $derived.by(() => {
const rows = Math.max(1, Math.ceil(series.length / 4));
return Math.min(120, 24 + rows * 18);
});
const chartPadding = $derived.by(() => ({
top: 4,
right: 4,
left: 20,
bottom: 20 + legendPadding,
}));
function formatDuration(value: number): string {
if (value === 0) return "0s";
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function formatYAxis(value: number): string {
if (value === 0) return "0s";
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
return hours > 0 ? `${hours}h` : `${minutes}m`;
}
</script>
<div
class="bg-dark/50 border border-white/10 rounded-xl p-6 flex flex-col min-h-[400px]"
>
<h2 class="mb-4 text-lg font-semibold text-white/90">Project Timeline</h2>
{#if data.length > 0}
<div class="h-[350px]">
<BarChart
{data}
x="week"
{series}
seriesLayout="stack"
legend
padding={chartPadding}
props={{
yAxis: { format: formatYAxis },
tooltip: { item: { format: formatDuration } },
legend: { classes: legendClasses },
}}
/>
</div>
{/if}
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import SocialProofUsers from "./SocialProofUsers.svelte";
type SocialProofUser = { display_name: string; avatar_url: string };
let {
wakatime_setup_path,
ssp_message,
ssp_users_recent,
ssp_users_size,
}: {
wakatime_setup_path: string;
ssp_message?: string | null;
ssp_users_recent: SocialProofUser[];
ssp_users_size: number;
} = $props();
</script>
<div class="text-left my-8 flex flex-col">
<p class="mb-4 text-xl text-primary">
Hello friend! Looks like you are new around here, let's get you set up
so you can start tracking your coding time.
</p>
<a
href={wakatime_setup_path}
class="inline-block w-auto text-3xl font-bold px-8 py-4 bg-primary text-white rounded shadow-md hover:shadow-lg hover:-translate-y-1 transition-all duration-300 animate-pulse"
>Let's setup Hackatime! Click me :D</a
>
<SocialProofUsers
users={ssp_users_recent}
total_size={ssp_users_size}
message={ssp_message}
/>
</div>

View file

@ -0,0 +1,82 @@
<script lang="ts">
type SocialProofUser = { display_name: string; avatar_url: string };
let {
users,
total_size,
message,
}: {
users: SocialProofUser[];
total_size: number;
message?: string | null;
} = $props();
</script>
<div class="flex items-center mt-4 flex-nowrap">
{#if users.length > 0}
<div class="flex m-0 ml-0 shrink-0">
{#each users as user, index}
<div
class={`relative cursor-pointer transition-transform duration-200 hover:-translate-y-1 hover:z-10 group ${index > 0 ? "-ml-4" : ""}`}
>
<div
class="absolute -top-9 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white px-2 py-1 rounded text-xs whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-20"
>
{user.display_name}
<div
class="absolute top-full left-1/2 -ml-1 border-l-2 border-r-2 border-t-2 border-transparent border-t-gray-800"
></div>
</div>
<img
src={user.avatar_url}
alt={user.display_name}
class="w-10 h-10 rounded-full border-2 border-primary object-cover shadow-sm"
/>
</div>
{/each}
{#if total_size > 5}
<div
class="relative cursor-pointer transition-transform duration-200 hover:-translate-y-1 hover:z-10 group -ml-4"
title={`See all ${total_size} users`}
>
<div
class="w-10 h-10 rounded-full border-2 border-primary bg-primary text-white font-bold text-sm flex items-center justify-center shadow-sm"
>
+{total_size - 5}
</div>
<div
class="absolute -left-5 top-11 bg-gray-800 rounded-lg shadow-xl p-4 w-80 z-50 max-h-96 overflow-y-auto opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200"
>
<h4
class="mt-0 mb-2 text-base text-gray-200 border-b border-gray-600 pb-2"
>
All users who set up Hackatime
</h4>
<div class="flex flex-col gap-2">
{#each users as user}
<div
class="flex items-center p-1 rounded hover:bg-gray-700 transition-colors duration-200"
>
<img
src={user.avatar_url}
alt={user.display_name}
class="w-8 h-8 rounded-full mr-2 border border-primary"
/>
<span class="font-medium text-sm">{user.display_name}</span>
</div>
{/each}
</div>
<div
class="absolute -top-2 left-8 w-0 h-0 border-l-2 border-r-2 border-b-2 border-transparent border-b-gray-800"
></div>
</div>
</div>
{/if}
</div>
{/if}
{#if message}
<p class="m-0 ml-2 italic text-gray-400">
{message} (this is real data)
</p>
{/if}
</div>

View file

@ -0,0 +1,40 @@
<script lang="ts">
let {
label,
value,
highlight = false,
subtitle = "",
}: {
label: string;
value: string | number;
highlight?: boolean;
subtitle?: string;
} = $props();
</script>
<div
class={`
relative flex flex-col justify-between p-4 pb-6 rounded-xl border transition-all duration-200 h-full
${
highlight
? "bg-primary/10 border-primary/30"
: "bg-white/5 border-white/10 hover:border-white/20"
}
`}
>
<div
class="text-secondary/80 text-xs font-medium uppercase tracking-wider mb-2"
>
{label}
</div>
<div
class={`font-bold text-white wrap-break-word ${String(value).length > 15 ? "text-lg" : "text-xl"}`}
>
{value || "—"}
</div>
{#if subtitle}
<span class="absolute bottom-2 left-4 text-xs text-secondary font-normal opacity-70"
>{subtitle}</span
>
{/if}
</div>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { pluralize, toSentence } from "./utils";
let {
show_logged_time_sentence,
todays_duration_display,
todays_languages,
todays_editors,
}: {
show_logged_time_sentence: boolean;
todays_duration_display: string;
todays_languages: string[];
todays_editors: string[];
} = $props();
</script>
<p>
{#if show_logged_time_sentence}
You've logged
{todays_duration_display}
{#if todays_languages.length > 0 || todays_editors.length > 0}
across
{#if todays_languages.length > 0}
{#if todays_languages.length >= 4}
{todays_languages.slice(0, 2).join(", ")}
<span title={todays_languages.slice(2).join(", ")}>
(& {todays_languages.length - 2}
{pluralize(
todays_languages.length - 2,
"other language",
"other languages",
)})
</span>
{:else}
{toSentence(todays_languages)}
{/if}
{/if}
{#if todays_languages.length > 0 && todays_editors.length > 0}
using
{/if}
{#if todays_editors.length > 0}
{toSentence(todays_editors)}
{/if}
{/if}
{:else}
No time logged today... but you can change that!
{/if}
</p>

View file

@ -0,0 +1,36 @@
export const pluralize = (count: number, singular: string, plural: string) =>
count === 1 ? singular : plural;
export const toSentence = (items: string[]) => {
if (items.length === 0) return "";
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
};
export const secondsToDisplay = (seconds?: number) => {
if (!seconds) return "0m";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
};
export const percentOf = (value: number, max: number) => {
if (!max || max === 0) return 0;
return Math.max(2, Math.round((value / max) * 100));
};
export const logScale = (value: number, maxVal: number): number => {
if (value === 0) return 0;
const minPercent = 5;
const maxPercent = 100;
const linearRatio = value / maxVal;
const logRatio = Math.log(value + 1) / Math.log(maxVal + 1);
const linearWeight = 0.8;
const logWeight = 0.2;
const scaled =
minPercent +
(linearWeight * linearRatio + logWeight * logRatio) *
(maxPercent - minPercent);
return Math.min(Math.round(scaled), maxPercent);
};

View file

@ -0,0 +1,368 @@
<script lang="ts">
import { onMount } from "svelte";
import { Link } from "@inertiajs/svelte";
import Stepper from "./Stepper.svelte";
interface Props {
current_user_api_key: string;
setup_os: string;
api_url: string;
heartbeat_check_url: string;
}
let { current_user_api_key, setup_os, api_url, heartbeat_check_url }: Props =
$props();
let activeSection = $derived(
setup_os === "windows" ? "windows" : "mac-linux",
);
let hasHeartbeat = $state(false);
let heartbeatTimeAgo = $state("");
let checkCount = $state(0);
let statusMessage = $state(
"Run the command above, then we'll automatically detect when you're ready.",
);
let statusPanelClass = $state("border-darkless");
const messages = [
"Copy the command above and run it in your terminal!",
"Paste the command and press Enter...",
"The script will configure everything automatically!",
"Almost there - just run the command!",
"We'll detect it as soon as the script runs!",
];
const sharedTitle = "Configure Hackatime";
const sharedSubtitle =
"This creates your config file and validates your API key. And if you're using VS Code, a JetBrains IDE, Zed, or Xcode, we'll even set up the plugins for you!";
function toggleSection(section: string) {
activeSection = section;
}
function showSuccess(timeAgo: string) {
hasHeartbeat = true;
heartbeatTimeAgo = timeAgo;
statusPanelClass = "border-green bg-green/5";
}
async function checkHeartbeat() {
try {
const response = await fetch(heartbeat_check_url, {
headers: {
Authorization: `Bearer ${current_user_api_key}`,
},
});
const data = await response.json();
if (data.has_heartbeat) {
const heartbeatTime = new Date(data.heartbeat.created_at);
const now = new Date();
const secondsAgo = (now.getTime() - heartbeatTime.getTime()) / 1000;
const recentThreshold = 300;
if (secondsAgo <= recentThreshold) {
showSuccess(data.time_ago);
return;
}
}
throw new Error("No heartbeats yet");
} catch (error) {
checkCount++;
if (checkCount % 3 === 0) {
const msgIndex = Math.floor(checkCount / 3) % messages.length;
statusMessage = messages[msgIndex];
}
}
}
onMount(() => {
checkHeartbeat();
const interval = setInterval(() => {
if (!hasHeartbeat) {
checkHeartbeat();
}
}, 5000);
return () => clearInterval(interval);
});
</script>
<svelte:head>
<title>Configure Hackatime - Step 1</title>
</svelte:head>
<div class="min-h-screen text-white pt-8 pb-16">
<div class="max-w-2xl mx-auto px-4">
<Stepper currentStep={1} />
<div class="space-y-8">
<div
class="border border-darkless rounded-xl p-6 bg-dark transition-all duration-300 {statusPanelClass}"
>
{#if !hasHeartbeat}
<div
class="flex flex-col items-center justify-center text-center py-2"
>
<h4 class="text-lg font-semibold text-white mb-1">
Waiting for setup...
</h4>
<p class="text-sm text-secondary mb-4 max-w-sm">{statusMessage}</p>
</div>
{:else}
<div class="text-center py-2">
<h4 class="text-xl font-bold text-white">Setup complete!</h4>
<p class="text-secondary text-sm mb-6">
Heartbeat detected {heartbeatTimeAgo}.
</p>
<a
href="/my/wakatime_setup/step-2"
class="inline-flex items-center justify-center bg-primary text-white px-6 py-2 rounded-lg font-semibold transition-all"
>
Continue to Step 2 →
</a>
</div>
{/if}
</div>
{#if activeSection === "mac-linux"}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-2">{sharedTitle}</h3>
<p class="text-secondary text-sm">{sharedSubtitle}</p>
</div>
<div
class="bg-blue/5 border border-blue/20 rounded-lg p-4 mb-6 flex gap-3"
>
<svg
class="w-5 h-5 text-blue shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="text-sm">
<p class="font-medium text-blue mb-1">Using GitHub Codespaces?</p>
<p class="text-secondary">
Look for the <strong>Terminal</strong> tab at the bottom of your
window. If you don't see it, press
<kbd
class="bg-darkless text-white px-1.5 py-0.5 rounded text-xs font-mono"
>Ctrl+`</kbd
>.
</p>
</div>
</div>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
>
1
</div>
<div>
<p class="font-medium mb-1">Open your terminal</p>
<p class="text-sm text-secondary">
Search for "Terminal" in Spotlight (Mac) or your applications
menu.
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
>
2
</div>
<div class="w-full">
<p class="font-medium mb-1">Run the install command</p>
<div class="relative group mt-2">
<div
class="bg-darker border border-darkless rounded-lg overflow-x-auto"
>
<pre
class="p-4 pr-20 text-sm font-mono text-cyan whitespace-pre-wrap break-all"><code
>curl -fsSL https://hack.club/setup/install.sh | bash -s -- {current_user_api_key}</code
></pre>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-darkless">
<details class="group">
<summary
class="cursor-pointer text-sm text-secondary hover:text-white flex items-center gap-2 transition-colors"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
Watch video tutorial
</summary>
<div
class="mt-4 rounded-lg overflow-hidden border border-darkless"
>
<iframe
width="100%"
height="300"
src="https://www.youtube.com/embed/QTwhJy7nT_w?modestbranding=1&rel=0"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</details>
</div>
</div>
{/if}
{#if activeSection === "windows"}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-2">{sharedTitle}</h3>
<p class="text-secondary text-sm">{sharedSubtitle}</p>
</div>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
>
1
</div>
<div>
<p class="font-medium mb-1">Open PowerShell</p>
<p class="text-sm text-secondary">
Press <kbd
class="bg-darkless text-white px-1.5 py-0.5 rounded text-xs font-mono"
>Win+R</kbd
>, type <code>powershell</code>, and press Enter.
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
>
2
</div>
<div class="w-full">
<p class="font-medium mb-1">Run the install command</p>
<p class="text-sm text-secondary mb-2">
Right-click in PowerShell to paste the command.
</p>
<div class="relative group mt-2">
<div
class="bg-darker border border-darkless rounded-lg overflow-x-auto"
>
<pre
class="p-4 pr-20 text-sm font-mono text-cyan whitespace-pre-wrap break-all"><code
>& ([scriptblock]::Create((irm https://hack.club/setup/install.ps1))) -ApiKey {current_user_api_key}</code
></pre>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-darkless">
<details class="group">
<summary
class="cursor-pointer text-sm text-secondary hover:text-white flex items-center gap-2 transition-colors"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
Watch video tutorial
</summary>
<div
class="mt-4 rounded-lg overflow-hidden border border-darkless"
>
<iframe
width="100%"
height="300"
src="https://www.youtube.com/embed/fX9tsiRvzhg?modestbranding=1&rel=0"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</details>
</div>
</div>
{/if}
{#if activeSection === "advanced"}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-2">{sharedTitle}</h3>
<p class="text-secondary text-sm">{sharedSubtitle}</p>
</div>
<div class="bg-purple/10 border border-purple/20 rounded-lg p-4 mb-4">
<p class="text-sm text-purple-200">
Create or edit <code
class="bg-purple/20 px-1.5 py-0.5 rounded text-white font-mono text-xs"
>~/.wakatime.cfg</code
> with the following content:
</p>
</div>
<div class="relative group">
<div
class="bg-darker border border-darkless rounded-lg overflow-x-auto"
>
<pre
class="p-4 pr-20 text-sm font-mono text-cyan whitespace-pre-wrap break-all"><code
>[settings]
api_url = {api_url}
api_key = {current_user_api_key}
heartbeat_rate_limit_seconds = 30</code
></pre>
</div>
</div>
</div>
{/if}
<div class="text-center">
<a
href="/my/wakatime_setup/step-2"
class="text-xs text-secondary hover:text-white transition-colors"
>Skip to next step</a
>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { Link } from '@inertiajs/svelte';
import Stepper from './Stepper.svelte';
const editors = [
{ id: 'vscode', name: 'VS Code', icon: '/images/editor-icons/vs-code-128.png' },
{ id: 'vim', name: 'Vim', icon: '/images/editor-icons/vim-128.png' },
{ id: 'neovim', name: 'Neovim', icon: '/images/editor-icons/neovim-128.png' },
{ id: 'emacs', name: 'Emacs', icon: '/images/editor-icons/emacs-128.png' },
{ id: 'pycharm', name: 'PyCharm', icon: '/images/editor-icons/pycharm-128.png' },
{ id: 'sublime', name: 'Sublime', icon: '/images/editor-icons/sublime-text-128.png' },
{ id: 'unity', name: 'Unity', icon: '/images/editor-icons/unity-128.png' },
{ id: 'godot', name: 'Godot', icon: '/images/editor-icons/godot-128.png' },
{ id: 'other', name: 'Other', icon: null, emoji: '🔧' }
];
</script>
<svelte:head>
<title>Choose your editor - Step 2</title>
</svelte:head>
<div class="min-h-screen text-white pt-8 pb-16">
<div class="max-w-2xl mx-auto px-4">
<Stepper currentStep={2} />
<div class="text-center mb-10">
<h2 class="text-2xl font-bold mb-2">Choose your editor</h2>
<p class="text-secondary">Select the editor you'll be using. You can set up more later!</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
{#each editors as editor}
<a
href={`/my/wakatime_setup/step-3?editor=${editor.id}`}
class="group flex flex-col items-center justify-center p-6 bg-dark border border-darkless rounded-xl hover:border-primary transition-all duration-200 hover:shadow-lg hover:shadow-primary/10">
<div class="w-16 h-16 mb-4 flex items-center justify-center transition-transform duration-200 group-hover:scale-110">
{#if editor.icon}
<img src={editor.icon} alt={editor.name} class="w-12 h-12 object-contain">
{:else}
<div class="w-12 h-12 flex items-center justify-center text-3xl">{editor.emoji}</div>
{/if}
</div>
<span class="font-medium text-white group-hover:text-primary transition-colors">{editor.name}</span>
</a>
{/each}
</div>
</div>
</div>

View file

@ -0,0 +1,483 @@
<script lang="ts">
import { onMount } from "svelte";
import { Link } from "@inertiajs/svelte";
import Stepper from "./Stepper.svelte";
interface Props {
current_user_api_key: string;
editor: string;
heartbeat_check_url: string;
}
let { current_user_api_key, editor, heartbeat_check_url }: Props = $props();
let hasHeartbeat = $state(false);
let heartbeatTimeAgo = $state("");
let detectedEditor = $state("");
let checkCount = $state(0);
let statusMessage = $state("Open a file in VS Code and start typing!");
let statusPanelClass = $state("border-darkless");
let copiedCode = $state("");
const messages = [
"Open any code file and start typing!",
"Try editing some code in VS Code...",
"Type a few characters in your editor!",
"We're watching for your first keystroke...",
"Make any edit in VS Code to continue!",
];
const editorData: Record<
string,
{
name: string;
icon: string;
methods: Array<{ name: string; code: string; note?: string }>;
}
> = {
vim: {
name: "Vim",
icon: "/images/editor-icons/vim-128.png",
methods: [
{
name: "Using vim-plug",
code: "Plug 'wakatime/vim-wakatime'",
note: "Then run :PlugInstall",
},
{
name: "Using Vundle",
code: "Plugin 'wakatime/vim-wakatime'",
note: "Then run :PluginInstall",
},
],
},
neovim: {
name: "Neovim",
icon: "/images/editor-icons/neovim-128.png",
methods: [
{
name: "Using lazy.nvim",
code: '{ "wakatime/vim-wakatime", lazy = false }',
},
{ name: "Using packer.nvim", code: "use 'wakatime/vim-wakatime'" },
{
name: "Using vim-plug",
code: "Plug 'wakatime/vim-wakatime'",
note: "Then run :PlugInstall",
},
],
},
emacs: {
name: "Emacs",
icon: "/images/editor-icons/emacs-128.png",
methods: [
{
name: "Using MELPA",
code: "M-x package-install RET wakatime-mode RET",
note: "Then add (global-wakatime-mode) to your config.",
},
{
name: "Using use-package",
code: "(use-package wakatime-mode\n :ensure t\n :config\n (global-wakatime-mode))",
},
],
},
};
function showSuccess(timeAgo: string, editorName: string) {
hasHeartbeat = true;
heartbeatTimeAgo = timeAgo;
detectedEditor = editorName;
statusPanelClass = "border-green bg-green/5";
}
async function checkHeartbeat() {
try {
const response = await fetch(heartbeat_check_url, {
headers: {
Authorization: `Bearer ${current_user_api_key}`,
},
});
const data = await response.json();
if (data.has_heartbeat) {
const heartbeatTime = new Date(data.heartbeat.created_at);
const now = new Date();
const secondsAgo = (now.getTime() - heartbeatTime.getTime()) / 1000;
const recentThreshold = 86400;
if (secondsAgo <= recentThreshold) {
showSuccess(data.time_ago, data.editor);
return;
}
}
throw new Error("No recent heartbeats");
} catch (error) {
checkCount++;
if (checkCount % 3 === 0) {
const msgIndex = Math.floor(checkCount / 3) % messages.length;
statusMessage = messages[msgIndex];
}
}
}
onMount(() => {
if (editor === "vscode") {
checkHeartbeat();
const interval = setInterval(() => {
if (!hasHeartbeat) {
checkHeartbeat();
}
}, 5000);
return () => clearInterval(interval);
}
});
</script>
<svelte:head>
<title>Setup {editor} - Step 3</title>
</svelte:head>
<div class="min-h-screen text-white pt-8 pb-16">
<div class="max-w-2xl mx-auto px-4">
<Stepper currentStep={3} />
{#if editor === "vscode"}
<div class="space-y-6">
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm">
<div class="flex items-center gap-4 mb-6">
<img
src="/images/editor-icons/vs-code-128.png"
alt="VS Code"
class="w-12 h-12 object-contain"
/>
<div>
<h3 class="text-xl font-semibold">
Install the VS Code Extension
</h3>
<p class="text-secondary text-sm">
Search for "WakaTime" in the marketplace.
</p>
</div>
</div>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
>
1
</div>
<div>
<p class="font-medium mb-1">Install the extension</p>
<p class="text-sm text-secondary">
Open VS Code, go to Extensions (squares icon), search for <strong
>WakaTime</strong
>, and click Install.
<a
href="https://marketplace.visualstudio.com/items?itemName=WakaTime.vscode-wakatime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Marketplace</a
>
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
>
2
</div>
<div>
<p class="font-medium mb-1">Restart & Code</p>
<p class="text-sm text-secondary">
Restart VS Code if prompted. Then, open any file and start
typing to send your first heartbeat.
</p>
</div>
</div>
<div class="pt-4 border-t border-darkless">
<details class="group">
<summary
class="cursor-pointer text-sm text-secondary hover:text-white flex items-center gap-2 transition-colors"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
How do I know it's working?
</summary>
<div class="mt-4 pl-6">
<p class="text-sm mb-3 text-secondary">
You'll see a clock icon in your status bar:
</p>
<img
src="https://hc-cdn.hel1.your-objectstorage.com/s/v3/95d2513ce4b0c1c147827d17ecb4c24540cd73cc_p.png"
alt="WakaTime status bar"
class="rounded-lg border border-darkless"
/>
</div>
</details>
</div>
</div>
</div>
<div
class="border border-darkless rounded-xl p-6 bg-dark transition-all duration-300 {statusPanelClass}"
>
{#if !hasHeartbeat}
<div
class="flex flex-col items-center justify-center text-center py-2"
>
<h4 class="text-lg font-semibold text-white mb-1">
Waiting for you to code...
</h4>
<p class="text-sm text-secondary mb-4 max-w-sm">
{statusMessage}
</p>
</div>
{:else}
<div class="text-center py-2">
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-green/10 flex items-center justify-center"
>
<svg
class="w-8 h-8 text-green"
fill="none"
stroke="currentColor"
stroke-width="3"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h4 class="text-xl font-bold text-white mb-2">
Heartbeat detected!
</h4>
<p class="text-secondary text-sm mb-6">
Received {heartbeatTimeAgo} from {detectedEditor &&
detectedEditor.toLowerCase() !== "vscode" &&
detectedEditor.toLowerCase() !== "vs code"
? detectedEditor
: "VS Code"}.
</p>
<a
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full transition-all transform hover:scale-[1.02] active:scale-[0.98]"
>
Continue →
</a>
</div>
{/if}
</div>
<div class="text-center">
<a
href="/my/wakatime_setup/step-4"
class="text-xs text-secondary hover:text-white transition-colors"
>Skip to finish</a
>
</div>
</div>
{:else if editor === "godot"}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
<div class="flex items-center gap-4 mb-6">
<img
src="/images/editor-icons/godot-128.png"
alt="Godot"
class="w-12 h-12 object-contain"
/>
<div>
<h3 class="text-xl font-semibold">Godot Setup</h3>
<p class="text-secondary text-sm">
Install the plugin with your preferred package manager.
</p>
</div>
</div>
<div class="space-y-4">
<p class="text-sm">
Godot requires a plugin installed for each project separately.
</p>
<div class="bg-darkless/50 rounded-lg p-4">
<ol class="list-decimal list-inside space-y-2 text-sm">
<li>Open your Godot project</li>
<li>Go to <strong>AssetLib</strong> tab</li>
<li>Search for <strong>"Godot Super Wakatime"</strong></li>
<li>Download and Install</li>
<li>
Enable in <strong>Project → Project Settings → Plugins</strong>
</li>
</ol>
</div>
<div class="pt-2">
<a
href="https://www.youtube.com/watch?v=a938RgsBzNg&t=29s"
target="_blank"
class="inline-flex items-center gap-2 text-cyan hover:underline text-sm font-medium"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"
><path
d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"
/></svg
>
Watch setup tutorial
</a>
</div>
</div>
</div>
<a
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
>
Next Step
</a>
{:else if editorData[editor]}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
<div class="flex items-center gap-4 mb-6">
<img
src={editorData[editor].icon}
alt={editorData[editor].name}
class="w-12 h-12 object-contain"
/>
<div>
<h3 class="text-xl font-semibold">
{editorData[editor].name} Setup
</h3>
<p class="text-secondary text-sm">
Install the plugin with your preferred package manager.
</p>
</div>
</div>
<div class="space-y-6">
{#each editorData[editor].methods as method, index}
{#if index > 0}
<div class="pt-6 border-t border-darkless"></div>
{/if}
<div>
<h4 class="text-sm font-medium mb-2 text-white">{method.name}</h4>
<div class="relative group">
<div
class="bg-darker border border-darkless rounded-lg overflow-x-auto"
>
<pre
class="p-4 pr-20 text-sm font-mono text-cyan whitespace-pre"><code
>{method.code}</code
></pre>
</div>
</div>
{#if method.note}
<p class="text-xs text-secondary mt-2">{@html method.note}</p>
{/if}
</div>
{/each}
</div>
</div>
<a
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
>
Next Step
</a>
{:else}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-2">Setup your Editor</h3>
<p class="text-secondary text-sm">
Install the plugin with your preferred package manager.
</p>
</div>
<div class="space-y-4">
<p class="text-sm">
Find your editor in the WakaTime documentation and follow the
installation steps. Use your Hackatime API key when prompted.
</p>
<div class="bg-yellow/10 border border-yellow/20 rounded-lg p-4">
<p class="text-yellow text-sm font-medium mb-1">⚠️ Important</p>
<p class="text-secondary text-sm">
Since you already ran the setup script in Step 1, you don't need
to configure the <code>api_url</code> or <code>api_key</code> again
- just install the plugin!
</p>
</div>
<div class="pt-4 grid grid-cols-2 gap-3">
<a
href="/docs/editors/pycharm"
class="flex items-center gap-3 bg-darkless/50 rounded-lg p-3 hover:bg-darkless transition-colors"
>
<img
src="/images/editor-icons/pycharm-128.png"
alt="PyCharm"
class="w-6 h-6"
/>
<span class="text-sm">PyCharm</span>
</a>
<a
href="/docs/editors/sublime-text"
class="flex items-center gap-3 bg-darkless/50 rounded-lg p-3 hover:bg-darkless transition-colors"
>
<img
src="/images/editor-icons/sublime-text-128.png"
alt="Sublime"
class="w-6 h-6"
/>
<span class="text-sm">Sublime Text</span>
</a>
<a
href="/docs/editors/unity"
class="flex items-center gap-3 bg-darkless/50 rounded-lg p-3 hover:bg-darkless transition-colors"
>
<img
src="/images/editor-icons/unity-128.png"
alt="Unity"
class="w-6 h-6"
/>
<span class="text-sm">Unity</span>
</a>
<a
href="https://wakatime.com/editors"
target="_blank"
class="flex items-center gap-3 bg-darkless/50 rounded-lg p-3 hover:bg-darkless transition-colors"
>
<div class="w-6 h-6 flex items-center justify-center">🌐</div>
<span class="text-sm">View all editors</span>
</a>
</div>
</div>
</div>
<a
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
>
Next Step
</a>
{/if}
</div>
</div>

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import Stepper from "./Stepper.svelte";
interface Props {
return_url?: string;
return_button_text: string;
}
let { return_url, return_button_text }: Props = $props();
let agreed = $state(false);
</script>
<svelte:head>
<title>Setup Complete - Step 4</title>
</svelte:head>
<div class="min-h-screen text-white pt-8 pb-16">
<div class="max-w-2xl mx-auto px-4">
<Stepper currentStep={4} />
<div class="bg-dark border border-darkless rounded-xl p-6 text-center">
<h1 class="text-lg font-bold mb-2">You're all set!</h1>
<p class="mb-8 text-sm">
Hackatime is configured and tracking your code.
</p>
<div class="bg-yellow text-black rounded-xl p-6 mb-8 text-left">
<div class="flex items-start gap-4">
<div>
<h3 class="font-bold mb-2">Fair Play Policy</h3>
<p class="text-sm mb-3">
We have sophisticated measures to detect time manipulation.
Attempting to cheat the system will result in a <strong
>permanent ban</strong
> from Hackatime and all Hack Club events.
</p>
<p class="text-sm">
We are a non-profit running on donations - please respect the
community and play fair!
</p>
</div>
</div>
<div class="mt-2 pt-6 border-t border-yellow/10 flex justify-center">
<label
class="flex items-center gap-3 cursor-pointer select-none group"
>
<input
type="checkbox"
bind:checked={agreed}
class="w-5 h-5 rounded border-darkless bg-darker text-primary focus:ring-primary focus:ring-offset-darker transition-colors cursor-pointer"
/>
<span class="font-medium">I understand and agree to the rules</span>
</label>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="/"
class="px-8 py-3 bg-primary border border-transparent text-white rounded-lg transition-all font-semibold transform active:scale-[0.98] text-center {agreed
? ''
: 'opacity-50 cursor-not-allowed pointer-events-none'}"
>
Let's get going!
</a>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
currentStep: number;
}
let { currentStep }: Props = $props();
const steps = [
{ number: 1, label: "Install" },
{ number: 2, label: "Editor" },
{ number: 3, label: "Plugin" },
{ number: 4, label: "Finish" }
];
</script>
<div class="mb-10">
<div class="relative flex items-center justify-between w-full max-w-2xl mx-auto">
<div class="absolute top-5 left-0 w-full h-0.5 bg-darkless -z-10"></div>
{#each steps as step}
<div class="flex flex-col items-center gap-2 bg-darker z-10 px-2">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors duration-200
{currentStep > step.number ? 'bg-green border-green text-darker' :
currentStep === step.number ? 'bg-primary border-primary text-white' :
'bg-dark border-darkless text-secondary'}">
{#if currentStep > step.number}
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else}
{step.number}
{/if}
</div>
<span class="text-xs font-medium {currentStep === step.number ? 'text-white' : 'text-secondary'}">{step.label}</span>
</div>
{/each}
</div>
</div>

9
app/javascript/types/globals.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
import type { FlashData, SharedProps } from '@/types'
declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: SharedProps
flashDataType: FlashData
errorValueType: string[]
}
}

View file

@ -0,0 +1,39 @@
export type FlashData = {
notice?: string;
alert?: string;
};
export type SharedProps = {};
export type LeaderboardEntryUser = {
id: number;
display_name: string;
avatar_url: string;
profile_path: string;
};
export type LeaderboardActiveProject = {
name: string;
repo_url: string | null;
};
export type LeaderboardEntry = {
rank: number;
is_current_user: boolean;
user: LeaderboardEntryUser;
total_seconds: number;
total_display: string;
streak_count: number;
active_project: LeaderboardActiveProject | null;
needs_github_link: boolean;
settings_path: string;
};
export type ActivityGraphData = {
start_date: string;
end_date: string;
duration_by_date: Record<string, number>;
busiest_day_seconds: number;
timezone_label: string;
timezone_settings_path: string;
};

2
app/javascript/types/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

View file

@ -11,5 +11,6 @@ class HandleEmailSigninJob < ApplicationJob
token = email_address.user.create_email_signin_token(continue_param: continue_param).token token = email_address.user.create_email_signin_token(continue_param: continue_param).token
LoopsMailer.sign_in_email(email_address.email, token).deliver_now LoopsMailer.sign_in_email(email_address.email, token).deliver_now
token
end end
end end

View file

@ -70,8 +70,8 @@ class MigrateUserFromHackatimeJob < ApplicationJob
cursorpos: heartbeat.cursor_position, cursorpos: heartbeat.cursor_position,
project_root_count: heartbeat.project_root_count, project_root_count: heartbeat.project_root_count,
is_write: heartbeat.is_write, is_write: heartbeat.is_write,
source_type: :wakapi_import, source_type: :wakapi_import
raw_data: heartbeat.attributes.slice(*Heartbeat.indexed_attributes) # raw_data: heartbeat.attributes.slice(*Heartbeat.indexed_attributes)
} }
{ {

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class CurrentlyHacking
def self.count
Cache::CurrentlyHackingCountJob.perform_now[:count]
end
def self.active_users
data = Cache::CurrentlyHackingJob.perform_now
data[:users].map do |user|
project = data[:active_projects][user.id]
{
id: user.id,
display_name: user.display_name,
slack_uid: user.slack_uid,
avatar_url: user.avatar_url,
active_project: project && { name: project.project_name, repo_url: project.repo_url }
}
end
end
end

View file

@ -1,6 +1,5 @@
class Heartbeat < ApplicationRecord class Heartbeat < ApplicationRecord
before_save :set_fields_hash! before_save :set_fields_hash!
before_save :set_raw_data!
include Heartbeatable include Heartbeatable
include TimeRangeFilterable include TimeRangeFilterable
@ -112,10 +111,6 @@ class Heartbeat < ApplicationRecord
%w[user_id branch category dependencies editor entity language machine operating_system project type user_agent line_additions line_deletions lineno lines cursorpos project_root_count time is_write] %w[user_id branch category dependencies editor entity language machine operating_system project type user_agent line_additions line_deletions lineno lines cursorpos project_root_count time is_write]
end end
def set_raw_data!
self.raw_data ||= self.attributes.slice(*self.class.indexed_attributes)
end
def soft_delete def soft_delete
update_column(:deleted_at, Time.current) update_column(:deleted_at, Time.current)
end end

View file

@ -1,136 +0,0 @@
<% content_for :title, "Documentation" %>
<% content_for :meta_description, "Complete documentation for Hackatime - learn how to track your coding time and use our API." %>
<div class="min-h-screen text-white">
<div class="max-w-6xl mx-auto px-6 py-8">
<h1 class="text-4xl font-bold mb-4">Welcome to <span class="text-primary">Hackatime</span></h1>
<p class="text-secondary text-lg mb-8">Tracks your coding time - made by <a href="https://hackclub.com" target="_blank" class="text-primary hover:text-red underline">Hack Club</a></p>
<div class="bg-dark rounded-lg p-6 mb-8">
<p class="mb-4">Hackatime is a free tool from Hack Club. It shows you how much time you spend coding. You can see what other Hack Clubbers are building too!</p>
<p class="mb-4"><strong class="text-primary">Why we made this:</strong> The more time you spend making things, the better you get at building cool projects!</p>
<p>Hackatime is totally free. Anyone can see the <a href="https://github.com/hackclub/hackatime" target="_blank" class="text-primary hover:text-red underline">code</a>. It's like <a href="https://wakatime.com" target="_blank" class="text-primary hover:text-red underline">WakaTime</a> but free and open source.</p>
</div>
<h3 class="text-2xl font-semibold text-primary mb-4">🎯 How to Start</h3>
<div class="bg-darkless rounded-lg p-6 mb-8">
<ol class="space-y-3 text-lg">
<li><strong class="text-white">Make an account</strong> at <a href="<%= root_url %>" class="text-primary hover:text-red underline">hackatime.hackclub.com</a></li>
<li><strong class="text-white">Do the setup</strong>: Go to our <a href="<%= my_wakatime_setup_path %>" class="text-primary hover:text-red underline">setup page</a></li>
<li><strong class="text-white">Add a plugin</strong>: Put WakaTime in your code editor</li>
<li><strong class="text-white">Start coding</strong>: It tracks your time by itself!</li>
</ol>
</div>
<div class="bg-dark border-l-4 border-primary rounded-r-lg p-6 mb-8"><strong class="text-primary">💡 Tip:</strong> The <a href="<%= my_wakatime_setup_path %>" class="text-primary hover:text-red underline">setup page</a> does everything for you. No hard stuff to figure out!</div>
<h3 class="text-2xl font-semibold text-primary mb-4">🔌 What Code Editors Work</h3>
<div class="bg-dark rounded-lg p-6 mb-8">
<p class="mb-6">Hackatime works with <strong class="text-white">any editor</strong> that has <a href="https://wakatime.com" target="_blank" class="text-primary hover:text-red underline">WakaTime</a>. Just add the WakaTime plugin to your editor:</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-darkless rounded-lg p-4">
<strong class="text-primary text-lg block mb-3">Popular Ones:</strong>
<ul class="space-y-2">
<li><%= link_to "VS Code", doc_path("editors/vs-code"), class: "text-white hover:text-primary" %></li>
<li><%= link_to "PyCharm", doc_path("editors/pycharm"), class: "text-white hover:text-primary" %></li>
<li><%= link_to "Vim", doc_path("editors/vim"), class: "text-white hover:text-primary" %></li>
<li><%= link_to "Sublime Text", doc_path("editors/sublime-text"), class: "text-white hover:text-primary" %></li>
</ul>
</div>
<div class="bg-darkless rounded-lg p-4">
<strong class="text-primary text-lg block mb-3">Lots More:</strong>
<ul class="space-y-2">
<li><%= link_to "Brackets", doc_path("editors/brackets"), class: "text-white hover:text-primary" %></li>
<li class="text-secondary">All JetBrains apps</li>
<li><%= link_to "Command Line", doc_path("editors/terminal"), class: "text-white hover:text-primary" %></li>
<li><a href="#supported-editors" onclick="document.getElementById('all-editors').parentElement.open = true; document.getElementById('all-editors').scrollIntoView({behavior: 'smooth'});" class="text-primary hover:text-red">70+ others</a></li>
</ul>
</div>
</div>
</div>
<h3 class="text-2xl font-semibold text-primary mb-4">📚 Help Pages</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-dark rounded-lg p-6">
<h4 class="text-xl font-semibold text-primary mb-4">Getting Started</h4>
<ul class="space-y-3">
<li><%= link_to "Quick Start Guide", doc_path("getting-started/quick-start"), class: "text-white hover:text-primary" %> <span class="text-secondary">- Start in 5 minutes</span></li>
<li><%= link_to "Installation", doc_path("getting-started/installation"), class: "text-white hover:text-primary" %> <span class="text-secondary">- Add plugins to your editor</span></li>
<li><%= link_to "Configuration", doc_path("getting-started/configuration"), class: "text-white hover:text-primary" %> <span class="text-secondary">- Advanced setup stuff</span></li>
</ul>
</div>
<div class="bg-dark rounded-lg p-6">
<h4 class="text-xl font-semibold text-primary mb-4">For Coders</h4>
<ul class="space-y-3">
<li><a href="/api-docs" class="text-white hover:text-primary">API Documentation</a> <span class="text-secondary">- Interactive API docs with Swagger</span></li>
</ul>
</div>
</div>
<h3 class="text-2xl font-semibold text-primary mb-4">🔒 Safe & Free</h3>
<div class="bg-dark rounded-lg p-6 mb-8">
<ul class="space-y-3">
<li><strong class="text-primary">Open Source</strong>: You can see all the code on <a href="https://github.com/hackclub/hackatime" target="_blank" class="text-primary hover:text-red underline">GitHub</a></li>
<li><strong class="text-primary">Works Offline</strong>: Time tracking continues even without internet - your data syncs when you reconnect</li>
<li><strong class="text-primary">Private</strong>: We only see how long you code, not what you type</li>
<li><strong class="text-primary">Community Made</strong>: Built by Hack Club</li>
<li><strong class="text-primary">Free</strong>: You can donate to cover server costs at <a href="https://hackclub.com/donate" target="_blank" class="text-primary hover:text-red underline">hackclub.com/donate</a></li>
</ul>
</div>
<h3 class="text-2xl font-semibold text-primary mb-4">🆘 Need Help?</h3>
<div class="bg-darkless rounded-lg p-6 mb-8">
<p class="mb-4">
<strong class="text-primary">Not seeing your time?</strong> Go to the <a href="<%= my_wakatime_setup_path %>" class="text-primary hover:text-red underline">setup page</a>
to check if everything is working.
</p>
<p><strong class="text-primary">Have questions?</strong> Ask in the <a href="https://hackclub.slack.com" target="_blank" class="text-primary hover:text-red underline">Hack Club Slack</a> (#hackatime-v2 channel) or <a href="https://github.com/hackclub/hackatime/issues" target="_blank" class="text-primary hover:text-red underline">ask on GitHub</a>.</p>
</div>
<h3 class="text-2xl font-semibold text-primary mb-4">🔧 Supported Editors</h3>
<div class="bg-dark rounded-lg p-6 mb-8">
<p class="mb-6">Hackatime works with any editor that supports WakaTime. Click on your editor below for setup instructions:</p>
<div id="supported-editors" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<!-- Most popular editors among teenagers, ordered by popularity -->
<% popular_editors = [['VS Code', 'vs-code'], %w[PyCharm pycharm], ['IntelliJ IDEA', 'intellij-idea'], ['Sublime Text', 'sublime-text'], %w[Vim vim], %w[Neovim neovim], ['Android Studio', 'android-studio'], %w[Xcode xcode], %w[Unity unity], %w[Godot godot], %w[Cursor cursor], %w[Zed zed], %w[Terminal terminal], %w[WebStorm webstorm], %w[Eclipse eclipse], %w[Emacs emacs], %w[Jupyter jupyter], %w[OnShape onshape]] %>
<% popular_editors.each do |name, slug| %>
<a href="<%= doc_path("editors/#{slug}") %>" class="bg-darkless rounded-lg p-3 hover:bg-primary/75 transition-colors text-center block">
<img src="/images/editor-icons/<%= slug %>-128.png" alt="<%= name %>" class="w-12 h-12 mx-auto mb-2">
<div class="text-sm text-white"><%= name %></div>
</a>
<% end %>
</div>
</div>
<details class="bg-dark rounded-lg p-6 mb-8">
<summary class="cursor-pointer font-semibold text-primary">View all 80 supported editors</summary>
<div id="all-editors" class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-3 mt-4 p-4">
<%
# All 80 editors - alphabetically sorted
all_editors = [['Android Studio', 'android-studio'], %w[AppCode appcode], %w[Aptana aptana], ['Arduino IDE', 'arduino-ide'], ['Azure Data Studio', 'azure-data-studio'], %w[Blender blender], %w[Brackets brackets], %w[Brave brave], ['C++ Builder', 'c++-builder'], %w[Canva canva], %w[Chrome chrome], %w[CLion clion], %w[Cloud9 cloud9], %w[Coda coda], %w[CodeTasty codetasty], %w[Cursor cursor], %w[DataGrip datagrip], %w[DataSpell dataspell], %w[DBeaver dbeaver], %w[Delphi delphi], %w[Discord discord], %w[Eclipse eclipse], %w[Edge edge], %w[Emacs emacs], %w[Eric eric], %w[Excel excel], %w[Figma figma], %w[Firefox firefox], %w[Gedit gedit], %w[Godot godot], %w[GoLand goland], ['HBuilder X', 'hbuilder-x'], ['IDA Pro', 'ida-pro'], ['IntelliJ IDEA', 'intellij-idea'], %w[Jupyter jupyter], %w[Kakoune kakoune], %w[Kate kate], %w[Komodo komodo], %w[Micro micro], %w[MPS mps], %w[Neovim neovim], %w[NetBeans netbeans], %w[Notepad++ notepad++], %w[Nova nova], %w[Obsidian obsidian], %w[OnShape onshape], %w[Oxygen oxygen], %w[PhpStorm phpstorm], %w[Postman postman], %w[PowerPoint powerpoint], %w[Processing processing], %w[Pulsar pulsar], %w[PyCharm pycharm], %w[ReClassEx reclassex], %w[Rider rider], ['Roblox Studio', 'roblox-studio'], %w[RubyMine rubymine], %w[RustRover rustrover], %w[Safari safari], %w[SiYuan siyuan], %w[Sketch sketch], %w[SlickEdit slickedit], ['SQL Server Management Studio', 'sql-server-management-studio'], ['Sublime Text', 'sublime-text'], %w[Terminal terminal], %w[TeXstudio texstudio], %w[TextMate textmate], %w[Trae trae], %w[Unity unity], ['Unreal Engine 4', 'unreal-engine-4'], %w[Vim vim], ['Visual Studio', 'visual-studio'], ['VS Code', 'vs-code'], %w[WebStorm webstorm], %w[Windsurf windsurf], %w[Wing wing], %w[Word word], %w[Xcode xcode], %w[Zed zed], ['Swift Playgrounds', 'swift-playgrounds']].sort_by { |editor| editor[0] }
%>
<% all_editors.each do |name, slug| %>
<a href="<%= doc_path("editors/#{slug}") %>" class="bg-darkless rounded p-2 hover:bg-primary/75 transition-colors text-center block">
<img src="/images/editor-icons/<%= slug %>-128.png" alt="<%= name %>" class="w-8 h-8 mx-auto mb-1">
<div class="text-xs text-white leading-tight"><%= name %></div>
</a>
<% end %>
</div>
</details>
<div class="border-t border-darkless my-8"></div>
<div class="text-center">
<p class="text-secondary">
Found an issue with the docs?
<a href="https://github.com/hackclub/hackatime" target="_blank" class="text-primary hover:text-red underline"> Edit on GitHub </a> - we'd love your help making them better!
</p>
</div>
</div>
</div>

View file

@ -1,115 +0,0 @@
<% content_for :title, "#{@title} - Hackatime Documentation" %>
<% content_for :meta_description, generate_doc_description(@content, @title) %>
<% content_for :meta_keywords, generate_doc_keywords(@doc_path, @title) %>
<% content_for :canonical_url, doc_url(@doc_path) %>
<% content_for :og_type, "article" %>
<% content_for :og_title, "#{@title} - Hackatime Documentation" %>
<!-- JSON-LD Structured Data -->
<% content_for :head do %>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": <%== @title.to_json %>,
"description": <%== generate_doc_description(@content, @title).to_json %>,
"url": <%== doc_url(@doc_path).to_json %>,
"dateModified": <%== (File.mtime(safe_docs_path("#{@doc_path}.md")).iso8601 rescue Time.current.iso8601).to_json %>,
"author": {
"@type": "Organization",
"name": "Hack Club",
"url": "https://hackclub.com"
},
"publisher": {
"@type": "Organization",
"name": "Hack Club",
"url": "https://hackclub.com"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": <%== doc_url(@doc_path).to_json %>
},
"about": {
"@type": "SoftwareApplication",
"name": "Hackatime",
"description": "Free and open source time tracking alternative to WakaTime"
}
}
</script>
<style>
.prose a {
color: var(--color-primary) !important;
text-decoration: underline !important;
font-weight: 500 !important;
}
.prose a:hover {
color: var(--color-red) !important;
}
</style>
<% end %>
<div class="min-h-screen text-white">
<div class="max-w-6xl mx-auto px-6 py-8">
<nav class="mb-8">
<% @breadcrumbs.each_with_index do |crumb, index| %>
<% if index == @breadcrumbs.length - 1 %>
<span class="text-primary"><%= crumb[:name] %></span>
<% else %>
<% if crumb[:is_link] && crumb[:path] %>
<%= link_to crumb[:name], crumb[:path], class: "text-secondary hover:text-primary" %>
<% else %>
<span class="text-secondary"><%= crumb[:name] %></span>
<% end %>
<span class="text-secondary mx-2">/</span>
<% end %>
<% end %>
</nav>
<div
class="bg-dark rounded-lg p-8 mb-8 prose prose-invert prose-lg max-w-none
prose-headings:text-primary prose-headings:font-bold prose-headings:leading-tight
prose-h1:text-4xl prose-h1:mb-6 prose-h1:text-primary prose-h1:mt-0
prose-h2:text-2xl prose-h2:mt-10 prose-h2:mb-4 prose-h2:text-primary prose-h2:border-b prose-h2:border-primary/20 prose-h2:pb-2
prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3 prose-h3:text-primary
prose-h4:text-lg prose-h4:mt-4 prose-h4:mb-2 prose-h4:text-white prose-h4:font-semibold
prose-p:text-white prose-p:leading-7 prose-p:mb-5
prose-a:text-primary prose-a:hover:text-red prose-a:underline prose-a:font-medium
prose-strong:text-white prose-strong:font-semibold
prose-em:text-secondary prose-em:italic
prose-code:bg-darkless prose-code:text-primary prose-code:px-2 prose-code:py-1 prose-code:rounded prose-code:text-sm prose-code:font-mono
prose-pre:bg-darkless prose-pre:border prose-pre:border-primary/20 prose-pre:rounded-lg prose-pre:p-4 prose-pre:overflow-x-auto
prose-pre:text-white prose-pre:text-sm
prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:bg-darkless prose-blockquote:pl-6 prose-blockquote:py-4 prose-blockquote:rounded-r-lg
prose-blockquote:text-secondary prose-blockquote:italic prose-blockquote:font-normal prose-blockquote:my-6
prose-ul:text-white prose-ul:mb-4 prose-ul:pl-6
prose-ol:text-white prose-ol:mb-4 prose-ol:pl-6
prose-li:text-white prose-li:mb-3 prose-li:leading-7 prose-li:pl-2
prose-table:border-collapse prose-table:border prose-table:border-primary/20 prose-table:rounded-lg prose-table:overflow-hidden prose-table:my-6
prose-th:bg-darkless prose-th:text-primary prose-th:font-semibold prose-th:p-3 prose-th:border prose-th:border-primary/20
prose-td:text-white prose-td:p-3 prose-td:border prose-td:border-primary/20
prose-img:rounded-lg prose-img:shadow-lg prose-img:mx-auto prose-img:block prose-img:max-w-24 prose-img:h-auto prose-img:my-4
prose-hr:border-primary/20 prose-hr:my-8
[&_ol>li::marker]:text-primary [&_ol>li::marker]:font-semibold
[&_ul>li::marker]:text-primary
[&_ol>li]:mb-3 [&_ol>li]:pl-2
[&_h2:not(:first-child)]:mt-10
[&_h3:not(:first-child)]:mt-8
[&_p_strong:first-child]:text-primary
[&_pre[class*='language-json']]:bg-darkless [&_pre[class*='language-json']]:border [&_pre[class*='language-json']]:border-primary/10
[&_pre[class*='language-bash']]:bg-darkless [&_pre[class*='language-bash']]:border [&_pre[class*='language-bash']]:border-primary/10
[&_img[alt*='PyCharm']]:w-16 [&_img[alt*='PyCharm']]:h-16 [&_img[alt*='PyCharm']]:mx-auto [&_img[alt*='PyCharm']]:block [&_img[alt*='PyCharm']]:my-4
[&_img[alt*='VS_Code']]:w-16 [&_img[alt*='VS_Code']]:h-16 [&_img[alt*='VS_Code']]:mx-auto [&_img[alt*='VS_Code']]:block [&_img[alt*='VS_Code']]:my-4
[&_img[alt*='IntelliJ']]:w-16 [&_img[alt*='IntelliJ']]:h-16 [&_img[alt*='IntelliJ']]:mx-auto [&_img[alt*='IntelliJ']]:block [&_img[alt*='IntelliJ']]:my-4
[&_img[src*='/images/editor-icons/']]:w-16 [&_img[src*='/images/editor-icons/']]:h-16 [&_img[src*='/images/editor-icons/']]:mx-auto [&_img[src*='/images/editor-icons/']]:block [&_img[src*='/images/editor-icons/']]:my-4
[&_.editor-steps]:bg-darkless [&_.editor-steps]:p-6 [&_.editor-steps]:rounded-lg [&_.editor-steps]:my-4
[&_.editor-steps_ol]:m-0
[&_.editor-steps_li]:mb-2">
<%= sanitize @rendered_content, tags: %w[h1 h2 h3 h4 h5 h6 p a ul ol li code pre strong em img div span table thead tbody tr th td hr br blockquote], attributes: %w[href src alt title class id] %>
</div>
<div class="text-center bg-darkless rounded-lg p-6">
<p class="text-secondary">
Found an issue with this page?
<a href="https://github.com/hackclub/hackatime/edit/main/docs/<%= @doc_path %>.md" target="_blank" class="text-primary hover:text-red underline">Edit it on GitHub</a> - we'd love your help making the docs better!
</p>
</div>
</div>
</div>

View file

@ -1,19 +0,0 @@
<div>
<h1 class="font-bold text-4xl">Extensions</h1>
<em class="m-4">These are third-party extensions that can be used with Hackatime. Everything in the list is community made and not guaranteed to work!</em>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="border rounded-lg p-4 bg-darkless">
<h2 class="font-bold text-2xl mb-2">Hackatime Desktop</h2>
<p>Desktop app for Hackatime. Runs on Mac, Windows, and Linux.</p>
<%= link_to 'Source', 'https://github.com/hackclub/hackatime-desktop', class: 'text-primary hover:underline', allow_host: true %> |
<%= link_to 'Install', 'https://github.com/hackclub/hackatime-desktop/releases', class: 'text-primary hover:underline', allow_host: true %>
</div>
<div class="border rounded-lg p-4 bg-darkless">
<h2 class="font-bold text-2xl mb-2">Cattatime</h2>
<p>A Tamagotchi system for Hackatime. Code, fill your cup, and get your pet rewards. Available for windows and mac.</p>
<%= link_to 'Source', 'https://github.com/joysudo/catatime/tree/master', class: 'text-primary hover:underline', allow_host: true %> |
<%= link_to 'Install', 'https://github.com/joysudo/catatime/releases/', class: 'text-primary hover:underline', allow_host: true %>
</div>
</div>
</div>

View file

@ -15,6 +15,9 @@
<link rel="canonical" href="<%= content_for(:canonical_url) || request.original_url %>"> <link rel="canonical" href="<%= content_for(:canonical_url) || request.original_url %>">
<!-- Enhanced SEO --> <!-- Enhanced SEO -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<meta name="theme-color" content="#ec3750"> <meta name="theme-color" content="#ec3750">
<meta name="msapplication-TileColor" content="#ec3750"> <meta name="msapplication-TileColor" content="#ec3750">
@ -163,6 +166,21 @@
<% if Sentry.get_trace_propagation_meta %> <% if Sentry.get_trace_propagation_meta %>
<%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %> <%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %>
<% end %> <% end %>
<%= vite_stylesheet_tag "application" %>
<%= vite_client_tag %>
<%= vite_typescript_tag "inertia" %>
<%= inertia_ssr_head %>
<%= vite_typescript_tag 'application' %>
<!--
If using a TypeScript entrypoint file:
vite_typescript_tag 'application'
If using a .jsx or .tsx entrypoint, add the extension:
vite_javascript_tag 'application.jsx'
Visit the guide for more information: https://vite-ruby.netlify.app/guide/rails
-->
</head> </head>
<body class="<%= content_for(:body_class) %> flex min-h-screen bg-darker" data-controller="nav"> <body class="<%= content_for(:body_class) %> flex min-h-screen bg-darker" data-controller="nav">
@ -194,12 +212,13 @@
</footer> </footer>
</main> </main>
<div class="fixed top-0 right-5 max-w-sm max-h-[80vh] bg-elevated border border-darkless rounded-b-xl shadow-lg z-1000 overflow-hidden transform -translate-y-full transition-transform duration-300 ease-out hidden" data-controller="currently-hacking" data-currently-hacking-target="container" data-currently-hacking-count-url-value="<%= currently_hacking_count_static_pages_path %>" data-currently-hacking-full-url-value="<%= currently_hacking_static_pages_path %>" data-currently-hacking-interval-value="60000"> <% currently_hacking_count = CurrentlyHacking.count %>
<div class="currently-hacking p-3 bg-elevated cursor-pointer select-none bg-dark flex items-center justify-between"> <div class="fixed top-0 right-5 max-w-sm max-h-[80vh] bg-dark border border-darkless rounded-b-xl shadow-lg z-1000 overflow-hidden transform translate-y-0 transition-transform duration-300 ease-out" data-controller="currently-hacking" data-currently-hacking-target="container" data-currently-hacking-count-url-value="<%= currently_hacking_count_static_pages_path %>" data-currently-hacking-full-url-value="<%= currently_hacking_static_pages_path %>" data-currently-hacking-interval-value="60000">
<div class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between">
<div class="text-white text-sm font-medium"> <div class="text-white text-sm font-medium">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse mr-2"></div> <div class="w-2 h-2 rounded-full bg-green-500 animate-pulse mr-2"></div>
<span data-currently-hacking-target="count" class="text-lg"></span> <span data-currently-hacking-target="count" class="text-lg"><%= pluralize(currently_hacking_count, "person") %> currently hacking</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html class="<%= Rails.env == 'production' ? 'production' : 'development' %>" data-theme="dark">
<head>
<title><%= @page_title || content_for(:title) || 'Hackatime' %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="color-scheme" content="dark">
<meta name="description" content="<%= @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
<meta name="keywords" content="<%= @meta_keywords || content_for(:meta_keywords) || 'coding time tracker, programming stats, wakatime alternative, free time tracking, code statistics, developer analytics, programming time, coding productivity' %>">
<meta name="author" content="Hack Club">
<meta name="robots" content="index, follow">
<link rel="canonical" href="<%= content_for(:canonical_url) || request.original_url %>">
<meta name="theme-color" content="#ec3750">
<meta name="msapplication-TileColor" content="#ec3750">
<meta property="og:title" content="<%= @og_title || content_for(:og_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">
<meta property="og:description" content="<%= @og_description || content_for(:og_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
<meta property="og:url" content="<%= content_for(:og_url) || request.original_url %>">
<meta property="og:type" content="<%= content_for(:og_type) || 'website' %>">
<meta property="og:image" content="<%= content_for(:og_image) || asset_path('favicon.png') %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Hackatime">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@hackclub">
<meta name="twitter:creator" content="@hackclub">
<meta name="twitter:title" content="<%= @twitter_title || content_for(:twitter_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">
<meta name="twitter:description" content="<%= @twitter_description || content_for(:twitter_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
<meta name="twitter:image" content="<%= content_for(:twitter_image) || asset_path('favicon.png') %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<% if current_user %>
<meta name="user-is-superadmin" content="<%= current_user.admin_level == 'superadmin' %>">
<meta name="user-is-admin" content="<%= current_user.admin_level == 'admin' %>">
<meta name="user-is-viewer" content="<%= current_user.admin_level == 'viewer' %>">
<% end %>
<%= yield :head %>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Hackatime",
"alternateName": "Hack Club Hackatime",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Any",
"description": "Track your coding time easily with Hackatime. A free tool to see how much time you spend programming in different languages and editors.",
"url": "https://hackatime.hackclub.com",
"downloadUrl": "https://hackatime.hackclub.com",
"sameAs": ["https://github.com/hackclub/hackatime", "https://hackatime.hackclub.com/docs"],
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
},
"author": {
"@type": "Organization",
"name": "Hack Club",
"url": "https://hackclub.com"
},
"softwareVersion": "2.0",
"datePublished": "2025-01-01",
"license": "https://opensource.org/licenses/MIT",
"programmingLanguage": "Ruby",
"codeRepository": "https://github.com/hackclub/hackatime",
"supportingData": "Free coding time tracker",
"featureList": ["Track coding time across 75+ editors", "See which languages you use most", "View daily coding statistics", "Compare with other high schoolers", "Free and open source"]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Hack Club",
"url": "https://hackclub.com",
"logo": "https://hackclub.com/logo.png",
"sameAs": ["https://twitter.com/hackclub", "https://github.com/hackclub"]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Hackatime",
"alternateName": "Hack Club Hackatime",
"url": "https://hackatime.hackclub.com"
}
</script>
<% if request.path == "/" %>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is Hackatime?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Hackatime is a free coding time tracker that helps you see how much time you spend programming. It tracks your coding time across different languages and editors."
}
},
{
"@type": "Question",
"name": "Is Hackatime free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes! Hackatime is completely free to use. There are no paid plans or hidden costs."
}
},
{
"@type": "Question",
"name": "How is Hackatime different from WakaTime?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Hackatime is free and open source, while WakaTime has paid plans. Hackatime gives you all features for free and you can host it yourself."
}
},
{
"@type": "Question",
"name": "Is Hackatime the same as WakaTime?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. Hackatime is a separate, independent open-source project built by Hack Club. While both track coding time, Hackatime is completely free and designed for high school students in the Hack Club community."
}
}
]
}
</script>
<% end %>
<%= favicon_link_tag asset_path('favicon.png'), type: 'image/png' %>
<script defer data-domain="hackatime.hackclub.com" src="https://plausible.io/js/script.file-downloads.hash.js"></script>
<%= stylesheet_link_tag :app %>
<%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %>
<% if Sentry.get_trace_propagation_meta %>
<%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %>
<% end %>
<%= vite_stylesheet_tag "application" %>
<%= vite_client_tag %>
<%= vite_typescript_tag "inertia" %>
<%= inertia_ssr_head %>
</head>
<body class="<%= content_for(:body_class) %> flex min-h-screen bg-darker">
<%= yield %>
</body>
</html>

View file

@ -1,4 +1,4 @@
<aside class="flex flex-col min-h-screen w-[250px] bg-dark text-white px-2 py-4 rounded-r-lg overflow-y-auto lg:block" data-nav-target="nav" style="scrollbar-width: none; -ms-overflow-style: none;"> <aside class="flex flex-col min-h-screen w-[250px] bg-dark text-white px-3 py-4 rounded-r-lg overflow-y-auto lg:block" data-nav-target="nav" style="scrollbar-width: none; -ms-overflow-style: none;">
<div class="space-y-4"> <div class="space-y-4">
<% flash.each do |name, msg| %> <% flash.each do |name, msg| %>
<% <%
@ -10,12 +10,11 @@
'border-primary text-primary' 'border-primary text-primary'
end end
%> %>
<div> <div class="rounded-md text-center text-sm px-3 py-2 <%= c %>"><%= msg %></div>
<div class="rounded-lg border text-center text-lg px-3 py-2 mb-2 <%= c %>"><%= msg %></div>
</div>
<% end %> <% end %>
<% if current_user %> <% if current_user %>
<div class="px-2 rounded-lg flex flex-col items-center gap-2"> <div class="flex flex-col items-center gap-2 pb-3 border-b border-darkless">
<%= render 'shared/user_mention', user: current_user %> <%= render 'shared/user_mention', user: current_user %>
<%= render 'static_pages/streak', user: current_user, show_text: true, turbo_frame: false %> <%= render 'static_pages/streak', user: current_user, show_text: true, turbo_frame: false %>
<% if current_user.admin_level != 0 %> <% if current_user.admin_level != 0 %>
@ -23,152 +22,177 @@
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
<div class="mb-1"> <div>
<%= link_to 'Login', slack_auth_path, class: 'block px-2 py-1 rounded-lg transition text-white font-bold bg-primary hover:bg-secondary text-lg' %> <%= link_to 'Login', slack_auth_path, class: 'block px-4 py-2 rounded-md transition text-white font-semibold bg-primary hover:bg-secondary text-center' %>
</div> </div>
<% end %> <% end %>
<div>
<div class="space-y-1 text-lg">
<div>
<%= link_to root_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(root_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Home
<% end %>
</div>
<div>
<%= link_to leaderboards_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(leaderboards_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Leaderboards
<% end %>
</div>
<% unless current_user %>
<div>
<%= link_to docs_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(docs_path) || request.path.start_with?('/docs') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Docs
<% end %>
</div>
<div>
<%= link_to extensions_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(extensions_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Extensions
<% end %>
</div>
<div>
<%= link_to "/what-is-hackatime", class: "block px-2 py-1 rounded-lg transition #{current_page?('/what-is-hackatime') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
What is Hackatime?
<% end %>
</div>
<% end %>
<% if current_user %>
<div>
<%= link_to my_projects_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(my_projects_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Projects
<% end %>
</div>
<div>
<%= link_to docs_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(docs_path) || request.path.start_with?('/docs') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Docs
<% end %>
</div>
<div>
<%= link_to extensions_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(extensions_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Extensions
<% end %>
</div>
<div>
<%= link_to my_settings_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(my_settings_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Settings
<% end %>
</div>
<div>
<%= link_to oauth_applications_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(oauth_applications_path) || request.path.start_with?('/oauth/applications') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
My OAuth Apps
<% end %>
</div>
<div>
<button type="button" onclick="showLogout()" class="w-full text-left cursor-pointer block px-[15px] py-2.5 rounded-lg transition hover:text-primary hover:bg-darkless">Logout</button>
</div>
<% end %>
<% dev_tool(nil, "div") do %>
<%= link_to letter_opener_web_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(letter_opener_web_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Letter Opener
<% end %>
<% end %>
<% dev_tool(nil, "div") do %>
<%= link_to '/rails/mailers', class: "block px-2 py-1 rounded-lg transition #{current_page?('/rails/mailers') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Mailers
<% end %>
<% end %>
<% if current_user&.admin_level == "admin" || current_user&.admin_level == "superadmin" %>
<% admin_tool(nil, "div") do %>
<%= link_to admin_timeline_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_timeline_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Review Timeline
<% end %>
<% end %>
<% elsif current_user&.admin_level == "viewer" %>
<% viewer_tool(nil, "div") do %>
<%= link_to admin_timeline_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_timeline_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Review Timeline
<% end %>
<% end %>
<% end %>
<% if current_user&.admin_level == "admin" || current_user&.admin_level == "superadmin" %> <nav class="space-y-1">
<% admin_tool(nil, "div") do %> <div>
<%= link_to admin_trust_level_audit_logs_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_trust_level_audit_logs_path) || request.path.start_with?('/admin/trust_level_audit_logs') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %> <%= link_to root_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(root_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Trust Level Logs Home
<% end %>
<% end %>
<% elsif current_user&.admin_level == "viewer" %>
<% viewer_tool(nil, "div") do %>
<%= link_to admin_trust_level_audit_logs_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_trust_level_audit_logs_path) || request.path.start_with?('/admin/trust_level_audit_logs') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Trust Level Logs
<% end %>
<% end %>
<% end %> <% end %>
<% if current_user&.admin_level == "admin" || current_user&.admin_level == "superadmin" %>
<% admin_tool(nil, "div") do %>
<%= link_to admin_admin_api_keys_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_admin_api_keys_path) || request.path.start_with?('/admin/admin_api_keys') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Admin API Keys
<% end %>
<% end %>
<% elsif current_user&.admin_level == "viewer" %>
<% viewer_tool(nil, "div") do %>
<%= link_to admin_admin_api_keys_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_admin_api_keys_path) || request.path.start_with?('/admin/admin_api_keys') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Admin API Keys
<% end %>
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to admin_admin_users_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_admin_users_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Admin Management
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to admin_deletion_requests_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_deletion_requests_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Account Deletions
<% pending_count = DeletionRequest.pending.count %>
<% if pending_count > 0 %>
<span class="ml-1 px-2 py-0.5 text-xs rounded-full bg-primary text-white font-semibold"><%= pending_count %></span>
<% end %>
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to good_job_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(good_job_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
GoodBoy
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to admin_oauth_applications_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_oauth_applications_path) || request.path.start_with?('/admin/oauth_applications') ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
All OAuth Apps
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to flipper_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(flipper_path) ? 'bg-primary text-primary' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Feature Flags
<% end %>
<% end %>
<%= render_activities(@activities) if defined?(@activities) %>
</div> </div>
</div>
<div>
<%= link_to leaderboards_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(leaderboards_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Leaderboards
<% end %>
</div>
<% unless current_user %>
<div>
<%= link_to docs_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(docs_path) || request.path.start_with?('/docs') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Docs
<% end %>
</div>
<div>
<%= link_to extensions_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(extensions_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Extensions
<% end %>
</div>
<div>
<%= link_to "/what-is-hackatime", class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?('/what-is-hackatime') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
What is Hackatime?
<% end %>
</div>
<% end %>
<% if current_user %>
<div>
<%= link_to my_projects_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(my_projects_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Projects
<% end %>
</div>
<div>
<%= link_to docs_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(docs_path) || request.path.start_with?('/docs') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Docs
<% end %>
</div>
<div>
<%= link_to extensions_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(extensions_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Extensions
<% end %>
</div>
<div>
<%= link_to my_settings_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(my_settings_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Settings
<% end %>
</div>
<div>
<%= link_to oauth_applications_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(oauth_applications_path) || request.path.start_with?('/oauth/applications') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
My OAuth Apps
<% end %>
</div>
<div>
<a type="button" onclick="showLogout()" class="w-full text-left block px-3 py-2 rounded-md text-sm transition-colors hover:bg-darkless">Logout</a>
</div>
<% end %>
<% if current_user&.admin_level.present? || Rails.env.development? %>
<div class="pt-2 mt-2 border-t border-darkless space-y-1">
<% dev_tool(nil, "div") do %>
<%= link_to letter_opener_web_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(letter_opener_web_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Letter Opener
<% end %>
<% end %>
<% dev_tool(nil, "div") do %>
<%= link_to '/rails/mailers', class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?('/rails/mailers') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Mailers
<% end %>
<% end %>
<% if current_user&.admin_level == "admin" || current_user&.admin_level == "superadmin" %>
<% admin_tool(nil, "div") do %>
<%= link_to admin_timeline_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_timeline_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Review Timeline
<% end %>
<% end %>
<% elsif current_user&.admin_level == "viewer" %>
<% viewer_tool(nil, "div") do %>
<%= link_to admin_timeline_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_timeline_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Review Timeline
<% end %>
<% end %>
<% end %>
<% if current_user&.admin_level == "admin" || current_user&.admin_level == "superadmin" %>
<% admin_tool(nil, "div") do %>
<%= link_to admin_trust_level_audit_logs_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_trust_level_audit_logs_path) || request.path.start_with?('/admin/trust_level_audit_logs') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Trust Level Logs
<% end %>
<% end %>
<% elsif current_user&.admin_level == "viewer" %>
<% viewer_tool(nil, "div") do %>
<%= link_to admin_trust_level_audit_logs_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_trust_level_audit_logs_path) || request.path.start_with?('/admin/trust_level_audit_logs') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Trust Level Logs
<% end %>
<% end %>
<% end %>
<% if current_user&.admin_level == "admin" || current_user&.admin_level == "superadmin" %>
<% admin_tool(nil, "div") do %>
<%= link_to admin_admin_api_keys_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_admin_api_keys_path) || request.path.start_with?('/admin/admin_api_keys') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Admin API Keys
<% end %>
<% end %>
<% elsif current_user&.admin_level == "viewer" %>
<% viewer_tool(nil, "div") do %>
<%= link_to admin_admin_api_keys_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_admin_api_keys_path) || request.path.start_with?('/admin/admin_api_keys') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Admin API Keys
<% end %>
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to admin_admin_users_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_admin_users_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Admin Management
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to admin_deletion_requests_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_deletion_requests_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Account Deletions
<% pending_count = DeletionRequest.pending.count %>
<% if pending_count > 0 %>
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-primary text-white font-medium"><%= pending_count %></span>
<% end %>
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to good_job_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(good_job_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
GoodBoy
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to admin_oauth_applications_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(admin_oauth_applications_path) || request.path.start_with?('/admin/oauth_applications') ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
All OAuth Apps
<% end %>
<% end %>
<% superadmin_tool(nil, "div") do %>
<%= link_to flipper_path, class: "block px-3 py-2 rounded-md text-sm transition-colors #{current_page?(flipper_path) ? 'bg-primary text-white' : 'hover:bg-darkless'}", data: { action: "click->nav#clickLink" } do %>
Feature Flags
<% end %>
<% end %>
</div>
<% end %>
<% if defined?(@activities) %>
<div class="pt-2">
<%= render_activities(@activities) %>
</div>
<% end %>
</nav>
</div> </div>
</aside> </aside>

View file

@ -114,7 +114,7 @@
<% if is_broken %> <% if is_broken %>
<div class="gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"> <div class="gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<p class="text-sm text-yellow-200/80 leading-relaxed"> <p class="text-sm text-yellow-200/80 leading-relaxed">
Your editor may not be set up properlywe're receiving invalid project names. This time is shown here but won't be counted accurately and may not be submitted to Hack Club programs. Your editor may not be set up properly - we're receiving invalid project names. This time is shown here but won't be counted accurately and may not be submitted to Hack Club programs.
</p> </p>
</div> </div>
<% end %> <% end %>

View file

@ -1,235 +0,0 @@
<div class="container">
<% if current_user&.trust_level == "red" %>
<div class="text-primary bg-red-500/10 border-2 border-red-500/20 p-4 text-center rounded-lg mb-4">
<div class="flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 14.5a6.5 6.5 0 1 0 0-13a6.5 6.5 0 0 0 0 13M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m1-5a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0z" clip-rule="evenodd" /></svg>
<span class="text-3xl font-bold block ml-2">Hold up! Your account has been banned for suspicious activity.</span>
</div>
<div>
<p class="text-primary text-left text-lg mb-2"><b>What does this mean?</b> Your account was convicted for fraud or abuse of Hackatime, such as using methods to gain an unfair advantage on the leaderboards or attempting to manipulate your coding time in any way. This restricts your access to participate in public leaderboards, but Hackatime will still track and display your time. This may also affect your ability to participate in current and future Hack Club events.</p>
<p class="text-primary text-left text-lg mb-2"><b>What can I do?</b> Account bans are non-negotiable, and will not be removed unless determined to have been issued incorrectly. In that case, it will automatically be removed. We take fraud very seriously and have a zero-tolerance policy for abuse. If you believe this was a mistake, please DM the <a href="https://hackclub.slack.com/team/U091HC53CE8" target="_blank" class="underline">Fraud Department</a> on Slack. We do not respond in any other channel, DM or thread.</p>
<p class="text-primary text-left text-lg mb-0"><b>Can I know what caused this?</b> No. We do not disclose the patterns that were detected. Releasing this information would only benefit fraudsters. The fraud team regularly investigates claims of false bans to increase the effectiveness of our detection systems to combat fraud.</p>
</div>
</div>
<% end %>
<div class="flex items-center space-x-2 mt-2">
<p class="italic text-gray-400 m-0">
<%= @flavor_text %>
</p>
</div>
<div id="clock" class="clockicons clock-display"></div>
<% if current_user %>
<h1 class="font-bold mt-1 mb-4 text-5xl text-center">Keep Track of <span class="text-primary">Your</span> Coding Time</h1>
<% else %>
<h1 class="font-bold mt-1 mb-4 text-5xl text-center">Track How Much You <span class="text-primary">Code</span></h1>
<div class="flex flex-col w-full max-w-[50vw] mx-auto mb-22">
<%= link_to hca_auth_path(continue: @continue_param), class: "inline-flex items-center justify-center w-full px-6 py-3 rounded text-white font-bold bg-primary hover:bg-primary/75 transition-colors", data: { turbo: false }, onclick: "let s=this.querySelector('.spinner'),i=this.querySelector('.icon');s.classList.remove('hidden');i.classList.add('hidden');this.style.cssText='pointer-events:none;opacity:0.7'" do %>
<span class="spinner mr-2 hidden"><%= render "shared/spinner", class: "h-6 w-6" %></span>
<img src="/images/icon-rounded.png" class="icon h-6 w-6 mr-2">
<span>Sign in with your Hack Club account</span>
<% end %>
<div class="flex items-center my-4">
<div class="flex-1 border-t border-darkless"></div>
<span class="px-4 text-gray-400 text-sm">or</span>
<div class="flex-1 border-t border-darkless"></div>
</div>
<div class="flex gap-2">
<%= form_tag email_auth_path, class: "relative flex-1", data: { turbo: false } do %>
<div class="relative">
<%= email_field_tag :email, nil, placeholder: "Enter your email to get a sign in link", required: true, class: "w-full px-3 py-3 pr-12 border border-darkless bg-dark placeholder-secondary rounded focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" %>
<button type="submit" class="absolute right-2 top-1/2 transform -translate-y-1/2 w-8 h-8 p-1 bg-blue-600 hover:bg-blue-700 rounded cursor-pointer border-none flex items-center justify-center transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13.3 20.275q-.3-.3-.3-.7t.3-.7L16.175 16H7q-.825 0-1.412-.587T5 14V5q0-.425.288-.712T6 4t.713.288T7 5v9h9.175l-2.9-2.9q-.3-.3-.288-.7t.288-.7q.3-.3.7-.312t.7.287L19.3 14.3q.15.15.212.325t.063.375t-.063.375t-.212.325l-4.575 4.575q-.3.3-.712.3t-.713-.3" /></svg>
</button>
</div>
<% end %>
<%= link_to slack_auth_path, class: "flex items-center justify-center px-4 py-3 rounded text-white cursor-pointer bg-dark hover:bg-darkless border border-darkless text-gray-300 transition-colors w-1/4 gap-2" do %>
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6"><path fill="currentColor" d="M6 15a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2h2zm1 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2a2 2 0 0 1-2-2zm2-8a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2v2zm0 1a2 2 0 0 1 2 2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2a2 2 0 0 1 2-2zm8 2a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2h-2zm-1 0a2 2 0 0 1-2 2a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2a2 2 0 0 1 2 2zm-2 8a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2v-2zm0-1a2 2 0 0 1-2-2a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2a2 2 0 0 1-2 2z" /></svg></span>
<span class="hidden xl:inline">Slack Sign In</span>
<% end %>
</div>
</div>
<% if params[:sign_in_email] %>
<div class="text-green-500 mt-4 text-center max-w-[50vw] mx-auto">Check your email for a sign-in link!</div>
<% dev_tool class: "text-center max-w-[50vw] mx-auto mb-4" do %>
Because you're on localhost, <%= link_to "click here to view the email", letter_opener_web_path %>
<% end %>
<% end %>
<div class="w-full flex justify-center overflow-x-none">
<p class="monospace text-center text-primary text-[22px] select-none whitespace-nowrap">==============================================/ h a c k /=============================================</p>
</div>
<div class="mt-8 mb-8">
<h1 class="font-bold mt-1 mb-1 text-4xl">Compatible with your favourite IDEs</h1>
<p class="text-primary monospace text-[20px]">Hackatime works with these code editors and more!</p>
<div id="supported-editors" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
<% popular_editors = [['VS Code', 'vs-code'], %w[PyCharm pycharm], ['IntelliJ IDEA', 'intellij-idea'], ['Sublime Text', 'sublime-text'], %w[Vim vim], %w[Neovim neovim], ['Android Studio', 'android-studio'], %w[Xcode xcode], %w[Unity unity], %w[Godot godot], %w[Cursor cursor], %w[Zed zed], %w[Terminal terminal], %w[WebStorm webstorm], %w[Eclipse eclipse], %w[Emacs emacs], %w[Jupyter jupyter], %w[OnShape onshape]] %>
<% popular_editors.each do |name, slug| %>
<a href="<%= doc_path("editors/#{slug}") %>" class="bg-darkless rounded-lg p-3 hover:bg-primary/20 transition-all duration-200 text-center block hover:-translate-y-0.5 hover:shadow-lg hover:shadow-primary/20">
<img src="/images/editor-icons/<%= slug %>-128.png" alt="<%= name %>" class="w-12 h-12 mx-auto mb-2">
<div class="text-sm text-white"><%= name %></div>
</a>
<% end %>
</div>
</div>
<div class="w-full flex justify-center overflow-x-none">
<p class="monospace text-center text-primary text-[22px] select-none whitespace-nowrap">-----------------------------------------------------------------------------------------------</p>
</div>
<div class="mt-8 mb-8">
<h1 class="font-bold mt-1 mb-1 text-4xl">Why Hackatime?</h1>
<% if @home_stats&.[](:seconds_tracked) && @home_stats&.[](:users_tracked) %>
<p class="text-primary monospace text-[20px]">
We've tracked over <span class="text-primary"><%= number_with_delimiter(@home_stats[:seconds_tracked] / 3600) %> <%= 'hour'.pluralize(@home_stats[:seconds_tracked] / 3600) %> </span> of coding time across <span class="text-primary"><%= number_with_delimiter(@home_stats[:users_tracked]) %> <%= 'high schooler'.pluralize(@home_stats[:users_tracked]) %> </span> since <span class="text-primary">2025</span>!
</p>
<% end %>
<div class="overflow-x-auto -mx-4 px-4 pb-4 no-scrollbar">
<div class="grid grid-cols-4 gap-4 mt-4 text-center h-30 min-w-[800px]">
<p class="flex flex-col text-3xl justify-center bg-darkless rounded-lg p-3"><span class="text-primary font-bold text-4xl">100%</span><br>free</p>
<p class="flex flex-col text-3xl justify-center bg-darkless rounded-lg p-3">works<br><span class="text-primary font-bold text-4xl">offline</span></p>
<p class="flex flex-col text-3xl justify-center bg-darkless rounded-lg p-3"><span class="text-primary font-bold text-4xl">real time</span><br>stats</p>
<p class="flex flex-col text-3xl justify-center bg-darkless rounded-lg p-3">rise to the<br><span class="text-primary font-bold text-4xl">top #1</span></p>
</div>
</div>
</div>
<div class="w-full flex justify-center overflow-x-none">
<p class="monospace text-center text-primary text-[22px] select-none whitespace-nowrap">--------------------------------| #hackclub | #hackclub | #hackclub |--------------------------------</p>
</div>
<div class="mt-8 mb-8">
<h1 class="font-bold mt-1 mb-1 text-4xl">For your favorite <span class="text-primary">Hack Club</span> events!</h1>
<p class="text-primary monospace text-[20px]">First class support for Hack Club events, and more.</p>
<div class="relative mt-4 mb-4 rounded-lg" style="background: linear-gradient(90deg, #2e1538 0%, #5a2d4a 50%, #a36b80 100%);">
<div class="flex flex-col md:flex-row">
<div class="w-full md:w-1/2 pl-8">
<img src="/images/flagship.png" class="h-auto w-full mx-auto md:mx-0">
</div>
<div class="w-full md:w-1/2 p-8 pl-4 pr-4 grid grid-cols-1 gap-4 ">
<div class="p-8 rounded-lg bg-no-repeat bg-center relative" style="background-image: url('/images/bgBanner.png'); background-size: 100% 100%;">
<h2 class="font-bold text-[22px] text-white drop-shadow-md mb-4 mt-6">Make your first game, get a free ticket to the craziest game jam of 2026!</h2>
<p class="text-white drop-shadow-md text-[18px] mb-4">Meet indie game developers and your favorite YouTubers.</p>
<%= link_to "Start building", "https://flagship.hackclub.com/?utm_source=hackatime", class: "inline-block relative z-10 font-primary font-bold px-6 py-3 rounded-full shadow-lg hover:scale-105 hover:shadow-xl transition-all duration-200", style: "background-color: #f5f0e8; color: #2a2a2a; font-size: 18px; padding-top: 14px;", target: "_blank" %>
</div>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row bg-linear-to-r from-[#EFCCCC] to-[#D35648] mt-4 mb-4 rounded-lg">
<div class="w-full md:w-1/3 -translate-y-5">
<img src="/images/athena.png" class="w-[400px]">
</div>
<div class="w-full md:w-2/3 p-8 pl-4 pr-4">
<img src="/images/athena_award.svg" class="h-24 mb-4">
<p class="text-[18px] m-4">Earn an <b>industry recognized technical certificate</b> for coding 30 hours and building 3 personal projects. Win prizes as you code, and a chance to travel to NYC for 2025's largest high school hackathon for girls.</p>
<%= link_to "Join Athena", "https://athena.hackclub.com/", class: "inline-block bg-white font-primary font-bold text-[#D35648] px-4 py-2 m-4 rounded-[100px] text-[22px] hover:scale-105 transition-transform duration-200", target: "_blank" %>
</div>
</div>
</div>
<% end %>
<% if current_user %>
<% if @show_wakatime_setup_notice %>
<div class="text-left my-8 flex flex-col">
<p class="mb-4 text-xl text-primary">Hello friend! Looks like you are new around here, let's get you set up so you can start tracking your coding time.</p>
<%= link_to "Let's setup Hackatime! Click me :D", my_wakatime_setup_path, class: "inline-block w-auto text-3xl font-bold px-8 py-4 bg-primary text-white rounded shadow-md hover:shadow-lg hover:-translate-y-1 transition-all duration-300 animate-pulse" %>
<div class="flex items-center mt-4 flex-nowrap">
<% if @ssp_users_recent&.any? %>
<div class="flex m-0 ml-0 shrink-0">
<% @ssp_users_recent.each_with_index do |user, index| %>
<div class="relative cursor-pointer transition-transform duration-200 hover:-translate-y-1 hover:z-10 group <%= index > 0 ? '-ml-4' : '' %>">
<div class="absolute -top-9 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white px-2 py-1 rounded text-xs whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-20">
<%= h(user[:display_name]) %>
<div class="absolute top-full left-1/2 -ml-1 border-l-2 border-r-2 border-t-2 border-transparent border-t-gray-800"></div>
</div>
<img src="<%= user[:avatar_url] %>" alt="<%= h(user[:display_name]) %>" class="w-10 h-10 rounded-full border-2 border-primary object-cover shadow-sm">
</div>
<% end %>
<% if @ssp_users_size && @ssp_users_size > 5 %>
<div class="relative cursor-pointer transition-transform duration-200 hover:-translate-y-1 hover:z-10 group -ml-4" title="See all <%= @ssp_users_size %> users">
<div class="w-10 h-10 rounded-full border-2 border-primary bg-primary text-white font-bold text-sm flex items-center justify-center shadow-sm">+<%= @ssp_users_size - 5 %></div>
<div class="absolute -left-5 top-11 bg-gray-800 rounded-lg shadow-xl p-4 w-80 z-50 max-h-96 overflow-y-auto opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<h4 class="mt-0 mb-2 text-base text-gray-200 border-b border-gray-600 pb-2">All users who set up Hackatime</h4>
<div class="flex flex-col gap-2">
<% @ssp_users_recent.each do |user| %>
<div class="flex items-center p-1 rounded hover:bg-gray-700 transition-colors duration-200">
<img src="<%= user[:avatar_url] %>" alt="<%= h(user[:display_name]) %>" class="w-8 h-8 rounded-full mr-2 border border-primary">
<span class="font-medium text-sm"><%= h(user[:display_name]) %></span>
</div>
<% end %>
</div>
<div class="absolute -top-2 left-8 w-0 h-0 border-l-2 border-r-2 border-b-2 border-transparent border-b-gray-800"></div>
</div>
</div>
<% end %>
</div>
<% end %>
<% if @ssp_message %>
<p class="m-0 ml-2 italic text-gray-400"><%= @ssp_message %> (this is real data)</p>
<% end %>
</div>
</div>
<% end %>
<% if current_user.github_uid.blank? %>
<div class="bg-dark border border-primary rounded-lg p-4 mb-6">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="text-white shrink-0"><path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" /></svg>
<div class="flex-1">
<span class="text-white">Link your GitHub account to unlock project linking, show what you're working on, and qualify for leaderboards!</span>
</div>
<%= link_to "Connect GitHub", "/auth/github", class: "bg-primary hover:bg-primary text-white font-medium px-4 py-2 rounded-lg transition-colors duration-200 flex-shrink-0", data: { turbo: false } %>
</div>
</div>
<% end %>
<p class="mt-2">
<% if @show_logged_time_sentence %>
You've logged
<%= short_time_detailed @todays_duration %>
<% if @todays_languages.any? || @todays_editors.any? %>
across
<% if @todays_languages.any? %>
<% if @todays_languages.length >= 4 %>
<%= @todays_languages[0..1].join(", ") %> <span title="<%= @todays_languages[2..].join(", ") %>">(& <%= pluralize(@todays_languages.length - 2, 'other language') %>)</span>
<% else %>
<%= @todays_languages.to_sentence %>
<% end %>
<% end %>
<% if @todays_languages.any? && @todays_editors.any? %>
using
<% end %>
<% if @todays_editors.any? %>
<%= @todays_editors.to_sentence %>
<% end %>
<% end %>
<% else %>
No time logged today... but you can change that!
<% end %>
</p>
<%= turbo_frame_tag "mini_leaderboard", src: mini_leaderboard_static_pages_path, loading: :lazy do %>
<%= render "leaderboards/mini_leaderboard_loading" %>
<% end %>
<%= turbo_frame_tag "filterable_dashboard", src: filterable_dashboard_static_pages_path, loading: :lazy do %>
<%= render "static_pages/filterable_dashboard_loading" %>
<% end %>
<%= turbo_frame_tag "activity_graph", src: activity_graph_static_pages_path, loading: :lazy do %>
<%= render 'static_pages/activity_graph_loading' %>
<% end %>
<% else %>
<% if @leaderboard %>
<h3>Today's Top Hack Clubbers</h3>
<%= render "leaderboards/mini_leaderboard", leaderboard: @leaderboard, current_user: nil %>
<% end %>
<div class="w-full flex justify-center overflow-x-none">
<p class="monospace text-center text-primary text-[22px] select-none whitespace-nowrap">==============================================/ h a c k /=============================================</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 my-8 items-center">
<div>
<h1 class="font-bold mt-1 mb-1 text-5xl">Start hacking with <span class="text-primary">Hackatime</span> now!</h1>
<p class="text-primary monospace text-[20px]">It is super easy to setup, here is a quick guide!</p>
</div>
<div class="w-full relative pb-[56.25%] h-0 overflow-hidden">
<iframe width="1280" height="720" src="https://www.youtube-nocookie.com/embed/FSIxV4u77WQ?rel=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen class="absolute top-0 left-0 w-full h-full rounded-lg"></iframe>
</div>
</div>
<% end %>
</div>

View file

@ -34,7 +34,7 @@
<div class="container"> <div class="container">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold mb-6 text-center">What is <span class="text-primary">Hackatime</span>?</h1> <h1 class="text-4xl font-bold mb-6 mt-4 text-center">What is <span class="text-primary">Hackatime</span>?</h1>
<div class="bg-dark rounded-lg p-8 mb-8"> <div class="bg-dark rounded-lg p-8 mb-8">
<p class="text-lg mb-6"><strong class="text-primary">Hackatime</strong> is a free, open-source coding time tracker built by <a href="https://hackclub.com" target="_blank" class="text-primary hover:text-red underline">Hack Club</a> for high school students and developers who want to understand their programming habits.</p> <p class="text-lg mb-6"><strong class="text-primary">Hackatime</strong> is a free, open-source coding time tracker built by <a href="https://hackclub.com" target="_blank" class="text-primary hover:text-red underline">Hack Club</a> for high school students and developers who want to understand their programming habits.</p>
@ -65,7 +65,7 @@
</div> </div>
</div> </div>
<div class="text-center text-gray-400 text-sm"> <div class="text-center text-gray-400 text-sm pb-4">
<p><strong>Hackatime</strong> is built and maintained by the Hack Club community. <%= link_to 'Learn more about Hack Club', 'https://hackclub.com', target: '_blank', class: 'text-primary hover:text-red underline' %>.</p> <p><strong>Hackatime</strong> is built and maintained by the Hack Club community. <%= link_to 'Learn more about Hack Club', 'https://hackclub.com', target: '_blank', class: 'text-primary hover:text-red underline' %>.</p>
</div> </div>
</div> </div>

View file

@ -13,9 +13,6 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🚀</span>
</div>
<h2 class="text-xl font-semibold text-white">Time Tracking Wizard</h2> <h2 class="text-xl font-semibold text-white">Time Tracking Wizard</h2>
</div> </div>
<p class="text-gray-300 mb-4">Get started with tracking your coding time in just a few minutes.</p> <p class="text-gray-300 mb-4">Get started with tracking your coding time in just a few minutes.</p>
@ -24,9 +21,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🌍</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_region">Region</h2> <h2 class="text-xl font-semibold text-white" id="user_region">Region</h2>
</div> </div>
<%= form_with model: @user, <%= form_with model: @user,
@ -49,9 +43,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">⚙️</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_hackatime_extension">Extension Settings</h2> <h2 class="text-xl font-semibold text-white" id="user_hackatime_extension">Extension Settings</h2>
</div> </div>
<%= form_with model: @user, <%= form_with model: @user,
@ -68,9 +59,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🪪</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_username">Username</h2> <h2 class="text-xl font-semibold text-white" id="user_username">Username</h2>
</div> </div>
<%= form_with model: @user, <%= form_with model: @user,
@ -94,9 +82,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">💬</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_slack_status">Slack Integration</h2> <h2 class="text-xl font-semibold text-white" id="user_slack_status">Slack Integration</h2>
</div> </div>
@ -139,9 +124,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🔒</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_privacy">Privacy Settings</h2> <h2 class="text-xl font-semibold text-white" id="user_privacy">Privacy Settings</h2>
</div> </div>
<%= form_with model: @user, <%= form_with model: @user,
@ -158,9 +140,6 @@
<div class="border-t border-darkless pt-4 mt-4 space-y-3"> <div class="border-t border-darkless pt-4 mt-4 space-y-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🗑️</span>
</div>
<h3 class="text-lg font-semibold text-white" id="delete_account">Delete Account</h3> <h3 class="text-lg font-semibold text-white" id="delete_account">Delete Account</h3>
</div> </div>
@ -175,9 +154,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🔑</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_api_key">API Key</h2> <h2 class="text-xl font-semibold text-white" id="user_api_key">API Key</h2>
</div> </div>
@ -190,9 +166,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🔗</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_github_account">Connected Accounts</h2> <h2 class="text-xl font-semibold text-white" id="user_github_account">Connected Accounts</h2>
</div> </div>
@ -251,9 +224,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">📊</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_stats_badges">Stats Badges</h2> <h2 class="text-xl font-semibold text-white" id="user_stats_badges">Stats Badges</h2>
</div> </div>
@ -321,9 +291,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 space-y-6"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 space-y-6">
<div> <div>
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">📄</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_config_file">Config File</h2> <h2 class="text-xl font-semibold text-white" id="user_config_file">Config File</h2>
</div> </div>
<p class="text-gray-300 text-sm mb-4">Your Wakatime configuration file for tracking coding time.</p> <p class="text-gray-300 text-sm mb-4">Your Wakatime configuration file for tracking coding time.</p>
@ -336,9 +303,6 @@
<div class="border-t border-darkless pt-6"> <div class="border-t border-darkless pt-6">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">🚚</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_migration_assistant">Migration Assistant</h2> <h2 class="text-xl font-semibold text-white" id="user_migration_assistant">Migration Assistant</h2>
</div> </div>
<p class="text-gray-300 text-sm mb-4">This will migrate your heartbeats from waka.hackclub.com to this platform.</p> <p class="text-gray-300 text-sm mb-4">This will migrate your heartbeats from waka.hackclub.com to this platform.</p>
@ -358,9 +322,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">📝</span>
</div>
<h2 class="text-xl font-semibold text-white" id="user_markscribe">Markscribe Templates</h2> <h2 class="text-xl font-semibold text-white" id="user_markscribe">Markscribe Templates</h2>
</div> </div>
<p class="text-gray-300 text-sm mb-4">Use markscribe to create beautiful GitHub profile READMEs with your coding stats.</p> <p class="text-gray-300 text-sm mb-4">Use markscribe to create beautiful GitHub profile READMEs with your coding stats.</p>
@ -384,9 +345,6 @@
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2"> <div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2">
<% if @user.trust_level == "red" %> <% if @user.trust_level == "red" %>
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">💾</span>
</div>
<h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2> <h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2>
</div> </div>
<div class="bg-red-500/20 border border-red-500 rounded-lg p-4"> <div class="bg-red-500/20 border border-red-500 rounded-lg p-4">
@ -397,9 +355,6 @@
</div> </div>
<% else %> <% else %>
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded">
<span class="text-2xl">💾</span>
</div>
<h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2> <h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2>
</div> </div>

View file

@ -1,355 +0,0 @@
<div class="min-h-screen text-white">
<div class="max-w-6xl mx-auto p-4">
<div class="text-center mb-4">
<h1 class="text-4xl font-bold text-primary mb-2">Hackatime Setup</h1>
<div class="flex items-center justify-center gap-2 mb-4">
<div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-sm font-bold">1</div>
<div class="w-16 h-1 bg-darkless"></div>
<div class="w-8 h-8 bg-darkless rounded-full flex items-center justify-center text-sm">2</div>
<div class="w-16 h-1 bg-darkless"></div>
<div class="w-8 h-8 bg-darkless rounded-full flex items-center justify-center text-sm">3</div>
<div class="w-16 h-1 bg-darkless"></div>
<div class="w-8 h-8 bg-darkless rounded-full flex items-center justify-center text-sm">4</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="setup-instructions">
<div id="mac-linux" class="bg-dark rounded-lg p-6" style="display: none;">
<h3 class="text-2xl font-bold text-green mb-4">🍎 Mac/Linux/Codespaces</h3>
<details class="mb-4 group">
<summary class="cursor-pointer text-blue hover:text-cyan flex items-center gap-2 border border-blue/30 rounded-lg p-3 bg-blue/10">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="font-semibold">On GitHub Codespaces? Click here first!</span>
</summary>
<div class="mt-3 pl-6 border-l-2 border-blue/30">
<p class="text-md mb-2">Here's how to find your terminal:</p>
<ol class="list-decimal list-inside text-md space-y-1">
<li>Look at the bottom of your Codespaces window for a panel</li>
<li>Click the <strong>"Terminal"</strong> tab (it's usually already open!)</li>
<li>If you don't see it, press <kbd class="bg-darkless px-1 rounded text-sm">Ctrl+`</kbd> (or <kbd class="bg-darkless px-1 rounded text-sm">Cmd+`</kbd> on Mac)</li>
</ol>
</div>
</details>
<div class="space-y-4 mb-4">
<ol class="list-decimal list-inside text-lg space-y-3">
<li>
<strong>Find your terminal:</strong>
<ul class="list-disc list-inside ml-4 mt-1 text-md space-y-1">
<li><strong>Mac:</strong> Open the "Terminal" app (search in Spotlight)</li>
<li><strong>Linux:</strong> Open your terminal emulator</li>
</ul>
</li>
<li><strong>Copy the command below</strong> (click the Copy button!)</li>
<li><strong>Paste it in your terminal</strong> and press <i>Enter</i></li>
</ol>
</div>
<div class="code-block bg-darkless rounded-lg p-4 mb-4">
<code class="text-cyan text-sm">curl -fsSL https://hack.club/setup/install.sh | bash -s -- <%= @current_user_api_key %></code>
<button class="copy-button bg-primary hover:bg-primary/75 border-0 text-white px-6 py-2 rounded transition-colors cursor-pointer font-semibold" onclick="copy(this)">Copy</button>
</div>
<details class="mb-4 group">
<summary class="cursor-pointer text-secondary hover:text-white flex items-center gap-2">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
Watch a video tutorial
</summary>
<div class="mt-3 rounded-lg">
<iframe width="100%" height="250" src="https://www.youtube.com/embed/QTwhJy7nT_w?loop=1&playlist=QTwhJy7nT_w&modestbranding=1&rel=0" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen class="rounded"></iframe>
</div>
</details>
<div class="flex gap-3 flex-wrap">
<button class="bg-blue hover:bg-blue/75 text-white border-0 px-4 py-2 rounded transition-colors cursor-pointer text-sm" onclick="toggleSection('windows')">Using Windows?</button>
<button class="bg-purple hover:bg-purple/75 text-white border-0 px-4 py-2 rounded transition-colors cursor-pointer text-sm" onclick="toggleSection('advanced')">Custom Setup</button>
</div>
</div>
<div id="windows" class="bg-dark rounded-lg p-6" style="display: none;">
<h3 class="text-2xl font-bold text-blue mb-4">🪟 Windows</h3>
<div class="space-y-4 mb-4">
<ol class="list-decimal list-inside text-lg space-y-3">
<li><strong>Open PowerShell:</strong> Press <kbd class="bg-darkless px-2 py-1 rounded text-md">Windows + R</kbd>, type "powershell", press Enter</li>
<li><strong>Copy the command below</strong> (click the Copy button!)</li>
<li><strong>Paste it</strong> (right-click in PowerShell, or <kbd class="bg-darkless px-1 rounded text-md">Ctrl+V</kbd>)</li>
</ol>
</div>
<div class="code-block bg-darkless rounded-lg p-4 mb-4">
<code class="text-cyan text-sm">& ([scriptblock]::Create((irm https://hack.club/setup/install.ps1))) -ApiKey <%= @current_user_api_key %></code>
<button class="copy-button bg-primary hover:bg-primary/75 border-0 text-white px-6 py-2 rounded transition-colors cursor-pointer font-semibold" onclick="copy(this)">Copy</button>
</div>
<details class="mb-4 group">
<summary class="cursor-pointer text-secondary hover:text-white flex items-center gap-2">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
Watch a video tutorial
</summary>
<div class="mt-3 rounded-lg">
<iframe width="100%" height="250" src="https://www.youtube.com/embed/fX9tsiRvzhg?loop=1&playlist=fX9tsiRvzhg&modestbranding=1&rel=0" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen class="rounded"></iframe>
</div>
</details>
<div class="flex gap-3 flex-wrap">
<button class="bg-green hover:bg-green/75 text-white border-0 px-4 py-2 rounded transition-colors cursor-pointer text-sm" onclick="toggleSection('mac-linux')">Using Mac/Linux?</button>
<button class="bg-purple hover:bg-purple/75 text-white border-0 px-4 py-2 rounded transition-colors cursor-pointer text-sm" onclick="toggleSection('advanced')">Custom Setup</button>
</div>
</div>
<div id="advanced" class="bg-dark rounded-lg p-6" style="display: none;">
<h3 class="text-2xl font-bold text-purple mb-4">⚙️ Custom Setup</h3>
<div class="bg-purple/10 border border-purple/30 rounded-lg p-4 mb-4">
<p class="text-sm">For advanced users who want to manually configure their setup.</p>
</div>
<p class="text-lg mb-4">Create or edit <span class="bg-darkless mx-2 px-2 py-1 rounded text-cyan text-md">~/.wakatime.cfg</span> with:</p>
<div class="code-block bg-darkless rounded-lg p-4 mb-4">
<code class="text-cyan text-sm">[settings]&#10;api_url = <%= api_hackatime_v1_url %>&#10;api_key = <%= @current_user_api_key %>&#10;heartbeat_rate_limit_seconds = 30</code>
<button class="copy-button bg-primary hover:bg-primary/75 border-0 text-white px-6 py-2 rounded transition-colors cursor-pointer font-semibold" onclick="copy(this)">Copy</button>
</div>
<div class="flex gap-3 flex-wrap">
<button class="bg-green hover:bg-green/75 text-white border-0 px-4 py-2 rounded transition-colors cursor-pointer text-sm" onclick="toggleSection('mac-linux')">Using Mac/Linux?</button>
<button class="bg-blue hover:bg-blue/75 text-white border-0 px-4 py-2 rounded transition-colors cursor-pointer text-sm" onclick="toggleSection('windows')">Using Windows?</button>
</div>
</div>
</div>
</div>
<div class="lg:col-span-1">
<div class="lg:sticky lg:top-4">
<section id="status-panel" class="bg-dark rounded-lg p-6 border-2 border-darkless">
<div id="waiting-state">
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto mb-3 rounded-full bg-primary/20 flex items-center justify-center">
<svg class="w-8 h-8 text-primary animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h4 class="text-xl font-bold text-white mb-2">Waiting for you to run the setup command...</h4>
<p class="text-secondary text-sm" id="status-message">Copy the command on the left and run it in your terminal!</p>
</div>
<div class="bg-darkless rounded-lg p-3">
<div class="flex items-center justify-center gap-2 text-secondary">
<div class="progress-indicator"></div>
<span class="text-sm" id="poll-status">Listening for heartbeats...</span>
</div>
</div>
</div>
<div id="success-state" class="hidden">
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto mb-3 rounded-full bg-green/20 flex items-center justify-center">
<svg class="w-8 h-8 text-green" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h4 class="text-xl font-bold text-green mb-2">Setup complete <span id="heartbeat-time-ago"></span>!</h4>
<p class="text-secondary text-sm">Hackatime is configured and ready to go.</p>
</div>
<%= link_to my_wakatime_setup_step_2_path, class: "block w-full bg-primary hover:bg-primary/75 text-white text-center px-6 py-3 rounded-lg font-semibold transition-colors" do %>
Continue →
<% end %>
</div>
</section>
<p class="text-center text-secondary text-xs mt-3">Already configured? <a href="<%= my_wakatime_setup_step_2_path %>" class="text-cyan hover:underline">Skip to next step</a></p>
</div>
</div>
</div>
</div>
</div>
<script>
function a() {
const ua = window.navigator.userAgent;
const mac = document.getElementById("mac-linux");
const windows = document.getElementById("windows");
mac.style.display = "none";
windows.style.display = "none";
if (ua.indexOf("Windows") !== -1) {
windows.style.display = "block";
} else {
mac.style.display = "block";
}
}
document.addEventListener("turbo:load", function () {
a();
window.toggleSection = function (section) {
const mac = document.getElementById("mac-linux");
const windows = document.getElementById("windows");
const advanced = document.getElementById("advanced");
mac.style.display = "none";
windows.style.display = "none";
advanced.style.display = "none";
if (section === "windows") {
windows.style.display = "block";
} else if (section === "advanced") {
advanced.style.display = "block";
} else {
mac.style.display = "block";
}
};
const waitingState = document.getElementById("waiting-state");
const successState = document.getElementById("success-state");
const statusMessage = document.getElementById("status-message");
const pollStatus = document.getElementById("poll-status");
const statusPanel = document.getElementById("status-panel");
let checkCount = 0;
const maxChecks = 120;
const msg = ["Copy the command on the left and run it in your terminal!", "Paste the command and press Enter...", "The script will configure everything automatically!", "Almost there - just run the command!", "We'll detect it as soon as the script runs!"];
function showSuccess(timeAgo) {
waitingState.classList.add("hidden");
successState.classList.remove("hidden");
statusPanel.classList.remove("border-darkless");
statusPanel.classList.add("border-green");
document.getElementById("heartbeat-time-ago").textContent = timeAgo;
}
function check() {
fetch(<%== api_v1_my_heartbeats_most_recent_path(source_type: "test_entry").to_json %>, {
headers: {
Authorization: "Bearer " + <%== @current_user_api_key.to_json %>,
},
})
.then((response) => response.json())
.then((data) => {
if (data.has_heartbeat) {
const heartbeatTime = new Date(data.heartbeat.created_at);
const now = new Date();
const secondsAgo = (now - heartbeatTime) / 1000;
const recentThreshold = 300;
if (secondsAgo <= recentThreshold) {
showSuccess(data.time_ago);
return;
}
}
throw new Error("No heartbeats yet");
})
.catch((error) => {
checkCount++;
if (checkCount % 3 === 0) {
const msgIndex = Math.floor(checkCount / 3) % msg.length;
statusMessage.textContent = msg[msgIndex];
}
pollStatus.textContent = `Checked ${checkCount} time${checkCount === 1 ? "" : "s"}...`;
if (checkCount >= maxChecks) {
pollStatus.textContent = "Still waiting... Make sure you've run the command!";
return;
}
setTimeout(check, 5000);
});
}
check();
window.skipToNext = function () {
window.location.href = <%== my_wakatime_setup_step_2_path.to_json %>;
};
});
function copy(button) {
const codeBlock = button.previousElementSibling;
const text = codeBlock.textContent;
navigator.clipboard.writeText(text).then(() => {
const originalText = button.textContent;
button.textContent = "✅ Copied!";
button.classList.add("bg-green");
button.classList.remove("bg-primary");
setTimeout(() => {
button.textContent = originalText;
button.classList.remove("bg-green");
button.classList.add("bg-primary");
}, 2000);
});
}
</script>
<style>
.progress-indicator {
width: 16px;
height: 16px;
border: 2px solid var(--color-darkless);
border-top: 2px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.setup-instructions {
width: 100%;
}
.code-block {
display: flex;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.code-block code {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
line-height: 1.4;
}
.copy-button {
flex-shrink: 0;
cursor: pointer;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.code-block {
flex-direction: column;
align-items: stretch;
}
.code-block code {
margin-bottom: 1rem;
}
iframe {
height: 200px;
}
}
</style>

View file

@ -1,109 +0,0 @@
<div class="min-h-screen text-white">
<div class="max-w-6xl mx-auto p-4">
<div class="text-center mb-4">
<h1 class="text-4xl font-bold text-primary mb-2">Hackatime Setup</h1>
<div class="flex items-center justify-center gap-2 mb-4">
<div class="w-8 h-8 bg-green rounded-full flex items-center justify-center text-sm font-bold">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="w-16 h-1 bg-green"></div>
<div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-sm font-bold">2</div>
<div class="w-16 h-1 bg-darkless"></div>
<div class="w-8 h-8 bg-darkless rounded-full flex items-center justify-center text-sm">3</div>
<div class="w-16 h-1 bg-darkless"></div>
<div class="w-8 h-8 bg-darkless rounded-full flex items-center justify-center text-sm">4</div>
</div>
</div>
<div class="text-center mb-8">
<h2 class="text-3xl font-bold mb-4">What editor do you use?</h2>
<p class="text-secondary text-lg"><em>Let's setup one for now, you can setup more later!</em></p>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-w-4xl mx-auto">
<%= link_to my_wakatime_setup_step_3_path(editor: "vscode"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/vs-code-128.png" alt="VS Code" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">VS Code</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "vim"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/vim-128.png" alt="Vim" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">Vim</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "neovim"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/neovim-128.png" alt="Neovim" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">Neovim</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "emacs"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/emacs-128.png" alt="Emacs" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">Emacs</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "pycharm"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/pycharm-128.png" alt="PyCharm" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">PyCharm</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "sublime"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/sublime-text-128.png" alt="Sublime" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">Sublime Text</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "unity"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/unity-128.png" alt="Unity" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">Unity</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "godot"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<img src="/images/editor-icons/godot-128.png" alt="Godot" class="w-16 h-16 object-contain">
</div>
<div>
<h3 class="font-bold text-lg">Godot</h3>
</div>
<% end %>
<%= link_to my_wakatime_setup_step_3_path(editor: "other"), class: "bg-dark rounded-lg p-6 hover:bg-darkless transition-colors flex flex-col items-center justify-center text-center gap-4 text-white no-underline group h-full col-span-2 md:col-span-3 lg:col-span-4" do %>
<div class="w-20 h-20 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
<div class="text-4xl">🔧</div>
</div>
<div>
<h3 class="font-bold text-lg">Other Editor</h3>
<p class="text-secondary text-sm">Hackatime supports 70+ editors</p>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -1,377 +0,0 @@
<div class="min-h-screen text-white">
<div class="max-w-6xl mx-auto p-4">
<div class="text-center mb-4">
<h1 class="text-4xl font-bold text-primary mb-2">Hackatime Setup</h1>
<div class="flex items-center justify-center gap-2 mb-4">
<div class="w-8 h-8 bg-green rounded-full flex items-center justify-center text-sm font-bold">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="w-16 h-1 bg-green"></div>
<div class="w-8 h-8 bg-green rounded-full flex items-center justify-center text-sm font-bold">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="w-16 h-1 bg-green"></div>
<div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-sm font-bold">3</div>
<div class="w-16 h-1 bg-darkless"></div>
<div class="w-8 h-8 bg-darkless rounded-full flex items-center justify-center text-sm">4</div>
</div>
</div>
<% if params[:editor] == "vscode" %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<section class="bg-dark rounded-lg p-6">
<h3 class="text-2xl font-bold text-blue mb-4">💻 Install the VS Code Extension</h3>
<div class="space-y-4">
<p class="text-lg">Install the <a href="https://marketplace.visualstudio.com/items?itemName=WakaTime.vscode-wakatime" target="_blank" rel="noopener noreferrer" class="text-cyan hover:text-blue underline font-semibold">WakaTime extension from the marketplace</a>.</p>
<h4 class="font-bold mb-2">Step-by-step:</h4>
<ol class="list-decimal list-inside space-y-1">
<li>Open VS Code</li>
<li>Click the Extensions icon (squares) on the left sidebar</li>
<li>Search for "WakaTime"</li>
<li>Click <strong>Install</strong> on the WakaTime extension</li>
<li>Restart VS Code if prompted</li>
<li>Code for a few minutes to start tracking your time to make sure it's working</li>
</ol>
<details class="group">
<summary class="cursor-pointer text-secondary hover:text-white flex items-center gap-2">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
How do I know it's working?
</summary>
<div class="mt-3 pl-6">
<p class="text-md mb-3">You'll see a clock icon in your status bar:</p>
<img src="https://hc-cdn.hel1.your-objectstorage.com/s/v3/95d2513ce4b0c1c147827d17ecb4c24540cd73cc_p.png" alt="WakaTime status bar in VS Code" class="max-w-full h-auto rounded-lg border border-darkless">
</div>
</details>
<details class="group">
<summary class="cursor-pointer text-secondary hover:text-white flex items-center gap-2">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
Why is the extension called WakaTime?
</summary>
<div class="mt-3 pl-6">
<p class="text-md mb-3">WakaTime is a popular time tracking service for developers. Hackatime is compatible with WakaTime&rsquo;s API, so you can install WakaTime plugins or extensions in your editor or IDE and configure them to send their data to Hackatime instead of the WakaTime service.</p>
</div>
</details>
</div>
</section>
</div>
<div class="lg:col-span-1">
<div class="lg:sticky lg:top-4">
<section id="status-panel" class="bg-dark rounded-lg p-6 border-2 border-darkless">
<div id="waiting-state">
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto mb-3 rounded-full bg-blue/20 flex items-center justify-center">
<svg class="w-8 h-8 text-blue animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<h4 class="text-xl font-bold text-white mb-2">Waiting for you to code...</h4>
<p class="text-secondary text-sm" id="status-message">Once you've installed the extension, open a file and start typing!</p>
</div>
<div class="bg-darkless rounded-lg p-3">
<div class="flex items-center justify-center gap-2 text-secondary">
<div class="spin"></div>
<span class="text-sm" id="poll-status">Checking for heartbeats...</span>
</div>
</div>
</div>
<div id="success-state" class="hidden">
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto mb-3 rounded-full bg-green/20 flex items-center justify-center">
<svg class="w-8 h-8 text-green" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h4 class="text-xl font-bold text-green mb-2">Heartbeat detected <span id="heartbeat-time-ago"></span>!</h4>
<p class="text-secondary text-sm" id="success-message">Hackatime is tracking your coding. Nice work!</p>
<p id="editor-mismatch-message" class="hidden text-cyan text-sm mt-2"></p>
</div>
<%= link_to my_wakatime_setup_step_4_path, class: "block w-full bg-primary hover:bg-red text-white text-center px-6 py-3 rounded-lg font-semibold transition-colors" do %>
Continue →
<% end %>
</div>
</section>
<p class="text-center text-secondary text-xs mt-3">Already set up? <a href="<%= my_wakatime_setup_step_4_path %>" class="text-cyan hover:underline">Skip to finish</a></p>
</div>
</div>
</div>
<% elsif params[:editor] == "vim" %>
<section id="vim" class="bg-dark rounded-lg p-6 mb-6">
<h3 class="text-2xl font-bold text-green mb-4">📟 Vim</h3>
<div class="space-y-6">
<p class="text-lg">Install the WakaTime plugin using your preferred plugin manager:</p>
<div class="space-y-4">
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Using vim-plug:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>Plug 'wakatime/vim-wakatime'</code></pre>
<p class="text-sm mt-2">Then run <code class="bg-darkless px-1 rounded text-cyan">:PlugInstall</code></p>
</div>
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Using Vundle:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>Plugin 'wakatime/vim-wakatime'</code></pre>
<p class="text-sm mt-2">Then run <code class="bg-darkless px-1 rounded text-cyan">:PluginInstall</code></p>
</div>
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Manual installation:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>cd ~/.vim/bundle
git clone https://github.com/wakatime/vim-wakatime.git</code></pre>
</div>
</div>
</div>
</section>
<div class="flex gap-4 flex-wrap justify-center">
<%= link_to my_wakatime_setup_step_4_path, class: "bg-primary hover:bg-red text-white px-6 py-3 rounded-lg font-semibold transition-colors" do %>
Next Step
<% end %>
</div>
<% elsif params[:editor] == "neovim" %>
<section id="neovim" class="bg-dark rounded-lg p-6 mb-6">
<h3 class="text-2xl font-bold text-green mb-4">📟 Neovim</h3>
<div class="space-y-6">
<p class="text-lg">Install the WakaTime plugin using your preferred plugin manager:</p>
<div class="space-y-4">
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Using lazy.nvim:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>{ "wakatime/vim-wakatime", lazy = false }</code></pre>
</div>
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Using packer.nvim:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>use 'wakatime/vim-wakatime'</code></pre>
</div>
<div class="bg-green/10 border border-green/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Using vim-plug:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>Plug 'wakatime/vim-wakatime'</code></pre>
<p class="text-sm mt-2">Then run <code class="bg-darkless px-1 rounded text-cyan">:PlugInstall</code></p>
</div>
</div>
</div>
</section>
<div class="flex gap-4 flex-wrap justify-center">
<%= link_to my_wakatime_setup_step_4_path, class: "bg-primary hover:bg-red text-white px-6 py-3 rounded-lg font-semibold transition-colors" do %>
Next Step
<% end %>
</div>
<% elsif params[:editor] == "emacs" %>
<section id="emacs" class="bg-dark rounded-lg p-6 mb-6">
<h3 class="text-2xl font-bold text-purple mb-4">🔮 Emacs</h3>
<div class="space-y-6">
<p class="text-lg">Install the WakaTime package using your preferred method:</p>
<div class="space-y-4">
<div class="bg-purple/10 border border-purple/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Using MELPA:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>M-x package-install RET wakatime-mode RET</code></pre>
<p class="text-sm mt-2">Then add to your config: <code class="bg-darkless px-1 rounded text-cyan">(global-wakatime-mode)</code></p>
</div>
<div class="bg-purple/10 border border-purple/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Using use-package:</h4>
<pre class="bg-darkless rounded p-3 text-cyan text-sm overflow-x-auto"><code>(use-package wakatime-mode
:ensure t
:config
(global-wakatime-mode))</code></pre>
</div>
</div>
</div>
</section>
<div class="flex gap-4 flex-wrap justify-center">
<%= link_to my_wakatime_setup_step_4_path, class: "bg-primary hover:bg-red text-white px-6 py-3 rounded-lg font-semibold transition-colors" do %>
Next Step
<% end %>
</div>
<% elsif params[:editor] == "godot" %>
<section id="godot" class="bg-dark rounded-lg p-6 mb-6">
<h3 class="text-2xl font-bold text-cyan mb-4">🎮 Godot</h3>
<div class="space-y-6">
<p class="text-lg">Follow our comprehensive <a href="/docs/editors/godot" class="text-cyan hover:text-blue underline">Godot & Hackatime Setup guide</a> with video tutorial!</p>
<div class="bg-cyan/10 border border-cyan/30 rounded-lg p-4">
<h4 class="font-bold mb-2">Quick steps:</h4>
<ol class="list-decimal list-inside space-y-1 text-sm">
<li>Open your Godot project</li>
<li>Go to the <strong>AssetLib</strong> tab</li>
<li>Search for <strong>"Godot Super Wakatime"</strong></li>
<li>Click <strong>Download</strong> and <strong>Install</strong></li>
<li>Enable in <strong>Project → Project Settings → Plugins</strong></li>
</ol>
<p class="text-sm mt-3 text-cyan">📺 <a href="https://www.youtube.com/watch?v=a938RgsBzNg&t=29s" target="_blank" class="underline">Watch the workshop recording</a> for a complete walkthrough!</p>
</div>
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
<p class="text-yellow text-sm"><strong>Note:</strong> You need to install the plugin for each Godot project separately (it's a Godot limitation).</p>
</div>
</div>
</section>
<div class="flex gap-4 flex-wrap justify-center">
<%= link_to my_wakatime_setup_step_4_path, class: "bg-primary hover:bg-red text-white px-6 py-3 rounded-lg font-semibold transition-colors" do %>
Next Step
<% end %>
</div>
<% else %>
<section class="bg-dark rounded-lg p-6 mb-6">
<h3 class="text-2xl font-bold text-orange mb-4">🔧 Setup your Editor</h3>
<div class="bg-orange/10 border border-orange/30 rounded-lg p-4 mb-4">
<p class="mb-4"><strong>Hackatime works with any editor that supports WakaTime!</strong> This includes PyCharm, IntelliJ, Sublime Text, Atom, Neovim, Unity, Godot, and <a href="https://hackatime.hackclub.com/docs#supported-editors" class="text-cyan hover:text-blue underline">77+ more editors</a>.</p>
</div>
<div class="space-y-4">
<div>
<h4 class="font-bold mb-2 text-lg">Popular Editors:</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<a href="/docs/editors/pycharm" class="flex items-center gap-3 bg-darkless rounded-lg p-3 hover:bg-primary/75 transition-colors">
<img src="/images/editor-icons/pycharm-128.png" alt="PyCharm" class="w-8 h-8">
<span>PyCharm</span>
</a>
<a href="/docs/editors/sublime-text" class="flex items-center gap-3 bg-darkless rounded-lg p-3 hover:bg-primary/75 transition-colors">
<img src="/images/editor-icons/sublime-text-128.png" alt="Sublime Text" class="w-8 h-8">
<span>Sublime Text</span>
</a>
<a href="/docs/editors/unity" class="flex items-center gap-3 bg-darkless rounded-lg p-3 hover:bg-primary/75 transition-colors">
<img src="/images/editor-icons/unity-128.png" alt="Unity" class="w-8 h-8">
<span>Unity</span>
</a>
<a href="/docs/editors/neovim" class="flex items-center gap-3 bg-darkless rounded-lg p-3 hover:bg-primary/75 transition-colors">
<img src="/images/editor-icons/neovim-128.png" alt="Neovim" class="w-8 h-8">
<span>Neovim</span>
</a>
</div>
</div>
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
<p class="text-yellow font-bold mb-2">⚠️ Important:</p>
<p>When setting up WakaTime plugins, <strong>skip any steps that ask you to update ~/.wakatime.cfg</strong> or change the api_url. The setup script from Step 1 already configured this correctly!</p>
</div>
</div>
</section>
<div class="flex gap-4 flex-wrap justify-center">
<%= link_to my_wakatime_setup_step_4_path, class: "bg-primary hover:bg-red text-white px-6 py-3 rounded-lg font-semibold transition-colors" do %>
Next Step
<% end %>
</div>
<% end %>
</div>
</div>
<% if params[:editor] == "vscode" %>
<script>
document.addEventListener("turbo:load", function () {
const waitingState = document.getElementById("waiting-state");
const successState = document.getElementById("success-state");
const statusMessage = document.getElementById("status-message");
const pollStatus = document.getElementById("poll-status");
const statusPanel = document.getElementById("status-panel");
let checkCount = 0;
const maxChecks = 120; // 10m (5s)
const msg = ["Open any code file and start typing!", "Try editing some code in VS Code...", "Type a few characters in your editor!", "We're watching for your first keystroke...", "Make any edit in VS Code to continue!"];
function showSuccess(timeAgo, detectedEditor) {
waitingState.classList.add("hidden");
successState.classList.remove("hidden");
statusPanel.classList.remove("border-darkless");
statusPanel.classList.add("border-green");
document.getElementById("heartbeat-time-ago").textContent = timeAgo;
if (detectedEditor && detectedEditor.toLowerCase() !== "vscode" && detectedEditor.toLowerCase() !== "vs code") {
const mismatchMessage = document.getElementById("editor-mismatch-message");
mismatchMessage.textContent = `We detected a heartbeat from ${detectedEditor}. If this is intended, you're all set!`;
mismatchMessage.classList.remove("hidden");
}
}
function check() {
fetch(<%== api_v1_my_heartbeats_most_recent_path.to_json %>, {
headers: {
Authorization: "Bearer " + <%== @current_user_api_key.to_json %>,
},
})
.then((response) => response.json())
.then((data) => {
if (data.has_heartbeat) {
const heartbeatTime = new Date(data.heartbeat.created_at);
const now = new Date();
const secondsAgo = (now - heartbeatTime) / 1000;
const recentThreshold = 86400;
if (secondsAgo <= recentThreshold) {
showSuccess(data.time_ago, data.editor);
return;
}
}
throw new Error("No recent heartbeats");
})
.catch((error) => {
checkCount++;
if (checkCount % 3 === 0) {
const msgIndex = Math.floor(checkCount / 3) % msg.length;
statusMessage.textContent = msg[msgIndex];
}
pollStatus.textContent = `Checked ${checkCount} time${checkCount === 1 ? "" : "s"}...`;
if (checkCount >= maxChecks) {
pollStatus.textContent = "Still waiting... Make sure the extension is installed!";
return;
}
setTimeout(check, 5000);
});
}
check();
window.skipToNext = function () {
window.location.href = <%== my_wakatime_setup_step_4_path.to_json %>;
};
});
</script>
<style>
.spin {
width: 16px;
height: 16px;
border: 2px solid var(--color-darkless);
border-top: 2px solid var(--color-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<% end %>

View file

@ -1,78 +0,0 @@
<div class="min-h-screen text-white">
<div class="max-w-6xl mx-auto p-4">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-primary mb-2">Hackatime Setup</h1>
<div class="flex items-center justify-center gap-2 mb-4">
<div class="w-8 h-8 bg-green rounded-full flex items-center justify-center text-sm font-bold">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="w-16 h-1 bg-green"></div>
<div class="w-8 h-8 bg-green rounded-full flex items-center justify-center text-sm font-bold">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="w-16 h-1 bg-green"></div>
<div class="w-8 h-8 bg-green rounded-full flex items-center justify-center text-sm font-bold">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="w-16 h-1 bg-green"></div>
<div class="w-8 h-8 bg-green rounded-full flex items-center justify-center text-sm font-bold">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</div>
<div class="text-center mb-8 mt-4 max-w-5xl mx-auto">
<h2 class="text-3xl font-bold mt-4 mb-4">Oh, and one more thing...</h2>
<p class="text-xl text-white mb-2"><b>Please do not try to cheat the system!</b> We have measures in place to detect and prevent cheating. If you attempt to manipulate Hackatime, you will be banned from Hackatime and other participating YSWS / events / programs, so please play fair! We are a non-profit organization and we run off of donations.</p>
<label class="flex items-center justify-center gap-2 cursor-pointer select-none mt-2">
<input type="checkbox" id="o" class="w-5 h-5 rounded border-gray-300 text-primary focus:ring-primary bg-white">
<span class="text-xl text-primary">I agree and I understand the rules.</span>
</label>
<p class="text-xl text-white mt-2">But besides that, you're all set! Happy hacking!</p>
</div>
<div class="text-center mb-8">
<div class="max-w-lg mx-auto">
<video src="<%= FlavorText.dino_meme_videos.sample %>" autoplay loop muted playsinline controls class="w-full rounded-lg"></video>
</div>
</div>
<div class="flex gap-4 flex-wrap justify-center">
<%= link_to my_wakatime_setup_step_2_path, class: "px-4 py-3 bg-dark hover:bg-darkless border border-darkless text-gray-300 rounded transition-colors cursor-pointer flex items-center justify-center" do %>
Set up another editor
<% end %>
<% if (url = session.dig(:return_data, "url")) %>
<%= link_to url, id: "s", class: "px-4 py-3 bg-primary hover:bg-primary/75 border border-darkless text-white rounded transition-colors cursor-pointer flex items-center justify-center opacity-50 cursor-not-allowed pointer-events-none" do %>
<%= session.dig(:return_data, 'button_text') || 'Done' %>
<% end %>
<% else %>
<%= link_to root_path, id: "s", class: "px-4 py-3 bg-primary hover:bg-primary/75 border border-darkless text-white rounded transition-colors cursor-pointer flex items-center justify-center opacity-50 cursor-not-allowed pointer-events-none" do %>
I agree, Get started!
<% end %>
<% end %>
<script>
document.getElementById("o").addEventListener("change", function () {
const x = document.getElementById("s");
if (this.checked) {
x.classList.remove("opacity-50", "cursor-not-allowed", "pointer-events-none");
x.disabled = false;
} else {
x.classList.add("opacity-50", "cursor-not-allowed", "pointer-events-none");
x.disabled = true;
}
});
</script>
</div>
</div>
</div>

29
bin/dev
View file

@ -1,18 +1,23 @@
#!/usr/bin/env sh #!/usr/bin/env sh
if ! gem list foreman -i --silent; then export PORT="${PORT:-3000}"
if command -v overmind 1> /dev/null 2>&1
then
overmind start -f Procfile.dev "$@"
exit $?
fi
if command -v hivemind 1> /dev/null 2>&1
then
echo "Hivemind is installed. Running the application with Hivemind..."
exec hivemind Procfile.dev "$@"
exit $?
fi
if gem list --no-installed --exact --silent foreman; then
echo "Installing foreman..." echo "Installing foreman..."
gem install foreman gem install foreman
fi fi
# Default to port 3000 if not specified foreman start -f Procfile.dev "$@"
export PORT="${PORT:-3000}"
# Let the debug gem allow remote connections,
# but avoid loading until `debugger` is called
export RUBY_DEBUG_OPEN="true"
export RUBY_DEBUG_LAZY="true"
# Use Procfile.dev, ensure web process binds to 0.0.0.0
# (Procfile.dev should have: web: bin/rails server -b 0.0.0.0)
exec foreman start -f Procfile.dev "$@"

View file

@ -14,6 +14,7 @@ FileUtils.chdir APP_ROOT do
puts "== Installing dependencies ==" puts "== Installing dependencies =="
system("bundle check") || system!("bundle install") system("bundle check") || system!("bundle install")
system! "npm install"
# puts "\n== Copying sample files ==" # puts "\n== Copying sample files =="
# unless File.exist?("config/database.yml") # unless File.exist?("config/database.yml")

16
bin/vite Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'vite' is installed as part of a gem, and
# this file is here to facilitate running it.
#
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "rubygems"
require "bundler/setup"
load Gem.bin_path("vite_ruby", "vite")

610
bun.lock Normal file
View file

@ -0,0 +1,610 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.13",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "5",
"chart.js": "^4.5.1",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "5",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@dagrejs/dagre": ["@dagrejs/dagre@1.1.8", "", { "dependencies": { "@dagrejs/graphlib": "2.2.4" } }, "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw=="],
"@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@inertiajs/core": ["@inertiajs/core@2.3.13", "", { "dependencies": { "@types/lodash-es": "^4.17.12", "axios": "^1.13.2", "laravel-precognition": "^1.0.1", "lodash-es": "^4.17.23", "qs": "^6.14.1" } }, "sha512-qMHRnb59k/HehXw/WfQt5kPV0k9RapfFcWJZINJnYMwfHDEJ21iNVZjsJHmDN7yWdZmG1Dxi9FP4xarWWgdosQ=="],
"@inertiajs/svelte": ["@inertiajs/svelte@2.3.13", "", { "dependencies": { "@inertiajs/core": "2.3.13", "@types/lodash-es": "^4.17.12", "laravel-precognition": "^1.0.1", "lodash-es": "^4.17.23" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0" } }, "sha512-tKqAGn3FCdLA57bmZjm+26exVjZVQ0I15/KuoEofZKjZ8/4bndyHhhx79jmelZKlDNj4O3ECz15L5mHfo7YPSQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@layerstack/svelte-actions": ["@layerstack/svelte-actions@1.0.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@layerstack/utils": "1.0.1", "d3-array": "^3.2.4", "d3-scale": "^4.0.2", "date-fns": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-Tv8B3TeT7oaghx0R0I4avnSdfAT6GxEK+StL8k/hEaa009iNOIGFl3f76kfvNvPioQHAMFGtnWGLPHfsfD41nQ=="],
"@layerstack/svelte-stores": ["@layerstack/svelte-stores@1.0.2", "", { "dependencies": { "@layerstack/utils": "1.0.1", "d3-array": "^3.2.4", "date-fns": "^4.1.0", "immer": "^10.1.1", "lodash-es": "^4.17.21", "zod": "^3.24.2" } }, "sha512-IxK0UKD0PVxg1VsyaR+n7NyJ+NlvyqvYYAp+J10lkjDQxm0yx58CaF2LBV08T22C3aY1iTlqJaatn/VHV4SoQg=="],
"@layerstack/tailwind": ["@layerstack/tailwind@1.0.1", "", { "dependencies": { "@layerstack/utils": "^1.0.1", "clsx": "^2.1.1", "culori": "^4.0.1", "d3-array": "^3.2.4", "date-fns": "^4.1.0", "lodash-es": "^4.17.21", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.15" } }, "sha512-nlshEkUCfaV0zYzrFXVVYRnS8bnBjs4M7iui6l/tu6NeBBlxDivIyRraJkdYGCSL1lZHi6FqacLQ3eerHtz90A=="],
"@layerstack/utils": ["@layerstack/utils@1.0.1", "", { "dependencies": { "d3-array": "^3.2.4", "date-fns": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-sWP9b+SFMkJYMZyYFI01aLxbg2ZUrix6Tv+BCDmeOrcLNxtWFsMYAomMhALzTMHbb+Vis/ua5vXhpdNXEw8a2Q=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.7", "", {}, "sha512-NOtJF9LQnV7k6bpzcXwL/rXdlFHvAT9e0imrftiMc6/+FUNBHRZ8UngDrM+jciA6ENzFYNoFs8rfwumuGF+Dhw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-geo-voronoi": ["d3-geo-voronoi@2.1.0", "", { "dependencies": { "d3-array": "3", "d3-delaunay": "6", "d3-geo": "3", "d3-tricontour": "1" } }, "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q=="],
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-interpolate-path": ["d3-interpolate-path@2.3.0", "", {}, "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
"d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-tile": ["d3-tile@1.0.0", "", {}, "sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-tricontour": ["d3-tricontour@1.1.0", "", { "dependencies": { "d3-delaunay": "6", "d3-scale": "4" } }, "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"irregular-plurals": ["irregular-plurals@4.2.0", "", {}, "sha512-bW9UXHL7bnUcNtTo+9ccSngbxc+V40H32IgvdVin0Xs8gbo+AVYD5g/72ce/54Kjfhq66vcZr8H8TKEvsifeOw=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"laravel-precognition": ["laravel-precognition@1.0.2", "", { "dependencies": { "axios": "^1.4.0", "lodash-es": "^4.17.21" } }, "sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg=="],
"layercake": ["layercake@8.4.3", "", { "dependencies": { "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0" }, "peerDependencies": { "svelte": "3 - 5 || >=5.0.0-next.120", "typescript": "^5.0.2" } }, "sha512-PZDduaPFxgHHkxlmsz5MVBECf6ZCT39DI3LgMVvuMwrmlrtlXwXUM/elJp46zHYzCE1j+cGyDuBDxnANv94tOQ=="],
"layerchart": ["layerchart@1.0.13", "", { "dependencies": { "@dagrejs/dagre": "^1.1.4", "@layerstack/svelte-actions": "^1.0.1", "@layerstack/svelte-stores": "^1.0.2", "@layerstack/tailwind": "^1.0.1", "@layerstack/utils": "^1.0.1", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-delaunay": "^6.0.4", "d3-dsv": "^3.0.1", "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "d3-geo-voronoi": "^2.1.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-interpolate-path": "^2.3.0", "d3-path": "^3.1.0", "d3-quadtree": "^3.0.1", "d3-random": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.1.0", "d3-shape": "^3.2.0", "d3-tile": "^1.0.0", "d3-time": "^3.1.0", "date-fns": "^4.1.0", "layercake": "8.4.3", "lodash-es": "^4.17.21" }, "peerDependencies": { "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0" } }, "sha512-bjcrfyTdHtfYZn7yj26dvA1qUjM+R6+akp2VeBJ4JWKmDGhb5WvT9nMCs52Rb+gSd/omFq5SjZLz49MqlVljZw=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"plur": ["plur@6.0.0", "", { "dependencies": { "irregular-plurals": "^4.2.0" } }, "sha512-Y9wXQivjRX0REtwpA9+n0bYYypWESn3cWtW2vazymw711qn+AQXxzZjRqhANYGBLIMC1UzVdpwe/1hHQwHfwng=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.49.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PYLwnngYzyhKzqDlGVlCH4z+NVI8mC0/bTv15vw25CcdOhxENsOHIbQ36oj5DIf3oBazM+STbCAvaskpxtBmWA=="],
"svelte-check": ["svelte-check@4.3.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@layerstack/tailwind/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"@layerstack/tailwind/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"@layerstack/tailwind/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"@layerstack/tailwind/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"@layerstack/tailwind/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"@layerstack/tailwind/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"@layerstack/tailwind/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
}
}

View file

@ -51,7 +51,6 @@ module Harbor
secure: Rails.env.production?, secure: Rails.env.production?,
httponly: true httponly: true
config.middleware.use HtmlCompressor::Rack
config.middleware.use Rack::Attack config.middleware.use Rack::Attack
config.exceptions_app = routes config.exceptions_app = routes
end end

View file

@ -1,6 +1,7 @@
# Pin npm packages by running ./bin/importmap # Pin npm packages by running ./bin/importmap
pin "application" pin "application"
pin "inertia_app"
pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"

View file

@ -11,7 +11,16 @@
# policy.img_src :self, :https, :data # policy.img_src :self, :https, :data
# policy.object_src :none # policy.object_src :none
# policy.script_src :self, :https # policy.script_src :self, :https
# Allow @vite/client to hot reload javascript changes in development
# policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development?
# You may need to enable this in production as well depending on your setup.
# policy.script_src *policy.script_src, :blob if Rails.env.test?
# policy.style_src :self, :https # policy.style_src :self, :https
# Allow @vite/client to hot reload style changes in development
# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development?
# # Specify URI for violation reports # # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint" # # policy.report_uri "/csp-violation-report-endpoint"
# end # end

View file

@ -48,20 +48,20 @@ Rails.application.configure do
cron: "0 0 * * *", cron: "0 0 * * *",
class: "SlackUsernameUpdateJob" class: "SlackUsernameUpdateJob"
}, },
# scan_github_repos: { scan_github_repos: {
# cron: "0 10 * * *", cron: "0 10 * * *",
# class: "ScanGithubReposJob" class: "ScanGithubReposJob"
# }, },
# sync_all_user_repo_events: { sync_all_user_repo_events: {
# cron: "0 */6 * * *", # Every 6 hours (at minute 0 of 0, 6, 12, 18 hours) cron: "0 */6 * * *", # Every 6 hours (at minute 0 of 0, 6, 12, 18 hours)
# class: "SyncAllUserRepoEventsJob", class: "SyncAllUserRepoEventsJob",
# description: "Periodically syncs repository events for all eligible users." description: "Periodically syncs repository events for all eligible users."
# }, },
# scan_repo_events_for_commits: { scan_repo_events_for_commits: {
# cron: "0 */3 * * *", # Every 3 hours at minute 0 cron: "0 */3 * * *", # Every 3 hours at minute 0
# class: "ScanRepoEventsForCommitsJob", class: "ScanRepoEventsForCommitsJob",
# description: "Scans repository host events (PushEvents) and enqueues jobs to process new commits." description: "Scans repository host events (PushEvents) and enqueues jobs to process new commits."
# }, },
# cleanup_expired_email_verification_requests: { # cleanup_expired_email_verification_requests: {
# cron: "* * * * *", # cron: "* * * * *",
# class: "CleanupExpiredEmailVerificationRequestsJob" # class: "CleanupExpiredEmailVerificationRequestsJob"

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
InertiaRails.configure do |config|
config.version = ViteRuby.digest
config.encrypt_history = true
config.always_include_errors_hash = true
config.use_script_element_for_initial_page = true
config.use_data_inertia_head_attribute = true
end

View file

@ -11,6 +11,11 @@ class AdminLevelConstraint
end end
Rails.application.routes.draw do Rails.application.routes.draw do
# Redirect to localhost from 127.0.0.1 to use same IP address with Vite server
constraints(host: "127.0.0.1") do
get "(*path)", to: redirect { |params, req| "#{req.protocol}localhost:#{req.port}/#{params[:path]}" }
end
mount Rswag::Api::Engine => "/api-docs" mount Rswag::Api::Engine => "/api-docs"
mount Rswag::Ui::Engine => "/api-docs" mount Rswag::Ui::Engine => "/api-docs"
use_doorkeeper use_doorkeeper
@ -77,12 +82,10 @@ Rails.application.routes.draw do
resources :static_pages, only: [ :index ] do resources :static_pages, only: [ :index ] do
collection do collection do
get :project_durations get :project_durations
get :activity_graph
get :currently_hacking get :currently_hacking
get :currently_hacking_count get :currently_hacking_count
get :filterable_dashboard_content get :filterable_dashboard_content
get :filterable_dashboard get :filterable_dashboard
get :mini_leaderboard
get :streak get :streak
# get :timeline # Removed: Old route for timeline # get :timeline # Removed: Old route for timeline
end end

16
config/vite.json Normal file
View file

@ -0,0 +1,16 @@
{
"all": {
"sourceCodeDir": "app/javascript",
"watchAdditionalPaths": []
},
"development": {
"autoBuild": true,
"publicOutputDir": "vite-dev",
"port": 3036
},
"test": {
"autoBuild": true,
"publicOutputDir": "vite-test",
"port": 3037
}
}

View file

@ -8,6 +8,7 @@ services:
volumes: volumes:
- .:/app - .:/app
- bundle_cache:/usr/local/bundle - bundle_cache:/usr/local/bundle
- node_modules:/app/node_modules
environment: environment:
- RAILS_ENV=development - RAILS_ENV=development
- DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development - DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development
@ -30,4 +31,5 @@ services:
volumes: volumes:
harbor_postgres_data: harbor_postgres_data:
bundle_cache: bundle_cache:
node_modules:

BIN
docs-index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
docs-vs-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

3652
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"private": true,
"type": "module",
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.13",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "5",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "5",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

View file

@ -1,70 +0,0 @@
try {
# Create config file with API settings
$configPath = "$env:USERPROFILE\.wakatime.cfg"
# If config exists, backup
if (Test-Path $configPath) {
Write-Host "[INFO] Config file already exists, moving into ~/.wakatime.cfg.bak"
Move-Item -Path $configPath -Destination "$configPath.bak"
}
@"
[settings]
api_url = $env:HACKATIME_API_URL
api_key = $env:HACKATIME_API_KEY
heartbeat_rate_limit_seconds = 30
"@ | Out-File -FilePath $configPath -Force -Encoding utf8
Write-Host "Config file created at $configPath"
# Verify config was created successfully
if (Test-Path $configPath) {
$config = Get-Content $configPath
$apiUrl = ($config | Select-String "api_url").ToString().Split('=')[1].Trim()
$apiKey = ($config | Select-String "api_key").ToString().Split('=')[1].Trim()
$heartbeatRate = ($config | Select-String "heartbeat_rate_limit_seconds").ToString().Split('=')[1].Trim()
# Display verification info
Write-Host "API URL: $apiUrl"
Write-Host ("API Key: " + $apiKey.Substring(0,4) + "..." + $apiKey.Substring($apiKey.Length-4)) # Show first/last 4 chars
# Send test heartbeat
Write-Host "Sending test heartbeat..."
$time = [Math]::Floor([decimal](Get-Date(Get-Date).ToUniversalTime()-uformat '%s'))
$heartbeat = @{
type = 'file'
time = $time
entity = 'test.txt'
language = 'Text'
}
$response = Invoke-RestMethod -Uri "$apiUrl/users/current/heartbeats" `
-Method Post `
-Headers @{Authorization="Bearer $apiKey"} `
-ContentType 'application/json' `
-Body "[$($heartbeat | ConvertTo-Json)]"
Write-Host "Test heartbeat sent successfully"
# Display ASCII art on success
Write-Host "`n" -NoNewline
Write-Host " _ _ _ _ _ " -ForegroundColor Cyan
Write-Host " | | | | __ _ ___| | ____ _| |_(_)_ __ ___ ___ " -ForegroundColor Cyan
Write-Host " | |_| |/ _` |/ __| |/ / _` | __| | '_ ` _ \ / _ \" -ForegroundColor Cyan
Write-Host " | _ | (_| | (__| < (_| | |_| | | | | | | __/" -ForegroundColor Cyan
Write-Host " |_| |_|\__,_|\___|_|\_\__,_|\__|_|_| |_| |_|\___|" -ForegroundColor Cyan
Write-Host "`n Ready to track! " -ForegroundColor Green
Write-Host " https://hackatime.hackclub.com " -ForegroundColor Yellow
Write-Host "`n"
} else {
throw "Failed to create config file"
}
} catch {
Write-Host "----------------------------------------"
Write-Host "ERROR: An error occurred during setup:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host "----------------------------------------"
}
finally {
Write-Host "Press any key to exit..."
$null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
}

View file

@ -1,55 +0,0 @@
#!/bin/bash
set -e
# If config exists, backup
if [ -f ~/.wakatime.cfg ]; then
echo "[INFO] Config file already exists, moving into ~/.wakatime.cfg.bak"
mv ~/.wakatime.cfg ~/.wakatime.cfg.bak
fi
# Create or update config file
cat > ~/.wakatime.cfg << EOL
[settings]
api_url = ${HACKATIME_API_URL}
api_key = ${HACKATIME_API_KEY}
heartbeat_rate_limit_seconds = 30
EOL
echo "Config file created at ~/.wakatime.cfg"
# Read values from config to verify
if [ ! -f ~/.wakatime.cfg ]; then
echo "Error: Config file not found"
exit 1
fi
API_URL=$(sed -n 's/.*api_url = \(.*\)/\1/p' ~/.wakatime.cfg)
API_KEY=$(sed -n 's/.*api_key = \(.*\)/\1/p' ~/.wakatime.cfg)
HEARTBEAT_RATE_LIMIT=$(sed -n 's/.*heartbeat_rate_limit_seconds = \(.*\)/\1/p' ~/.wakatime.cfg)
if [ -z "$API_URL" ] || [ -z "$API_KEY" ] || [ -z "$HEARTBEAT_RATE_LIMIT" ]; then
echo "Error: Could not read api_url, api_key, or heartbeat_rate_limit_seconds from config"
exit 1
fi
echo "Successfully read config:"
echo "API URL: $API_URL"
echo "API Key: ${API_KEY:0:8}..." # Show only first 8 chars for security
# Send test heartbeat using values from config
echo "Sending test heartbeat..."
response=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/users/current/heartbeats" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"file\",\"time\":$(date +%s),\"entity\":\"test.txt\",\"language\":\"Text\"}]")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "200" ] || [ "$http_code" = "202" ]; then
curl "$SUCCESS_URL"
echo -e "\nTest heartbeat sent successfully"
else
echo -e "\nError sending heartbeat: $body"
exit 1
fi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

View file

@ -1,7 +0,0 @@
_____ _
/ ____| | |
| (___ _ _ ___ ___ ___ ___ ___| |
\___ \| | | |/ __/ __/ _ \ __/ __| |
____) | |_| | (__ (__ __\__ \__ \_|
|_____/ \__,_|\___\___\___|___/___(_)

7
svelte.config.js Normal file
View file

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
/* Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["app/javascript/*"],
"~/*": ["app/javascript/*"]
}
},
"include": ["app/javascript/**/*.ts", "app/javascript/**/*.js", "app/javascript/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

12
tsconfig.node.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
},
"include": ["vite.config.ts"]
}

12
vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
export default defineConfig({
plugins: [
svelte(),
tailwindcss(),
RubyPlugin(),
],
})