Upgrade Inertia + new settings page (#950)

* New settings?

* New Settings page!

* Vendor Inertia

* Fix some issues

* <Link>ify the site!
This commit is contained in:
Mahad Kalam 2026-02-15 17:32:26 +00:00 committed by GitHub
parent c588258aba
commit 42ceec73cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 11304 additions and 1008 deletions

View file

@ -58,7 +58,7 @@ class InertiaController < ApplicationController
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("Settings", my_settings_path, active: request.path.start_with?("/my/settings"))
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

View file

@ -0,0 +1,43 @@
class Settings::AccessController < Settings::BaseController
def show
render_access
end
def update
if @user.update(access_params)
PosthogService.capture(@user, "settings_updated", { fields: access_params.keys })
redirect_to my_settings_access_path, notice: "Settings updated successfully"
else
flash.now[:error] = @user.errors.full_messages.to_sentence.presence || "Failed to update settings"
render_access(status: :unprocessable_entity)
end
end
def rotate_api_key
@user.api_keys.transaction do
@user.api_keys.destroy_all
new_api_key = @user.api_keys.create!(name: "Hackatime key")
PosthogService.capture(@user, "api_key_rotated")
render json: { token: new_api_key.token }, status: :ok
end
rescue => e
Rails.logger.error("error rotate #{e.class.name} #{e.message}")
render json: { error: "cant rotate" }, status: :unprocessable_entity
end
private
def render_access(status: :ok)
render_settings_page(
active_section: "access",
settings_update_path: my_settings_access_path,
status: status
)
end
def access_params
params.require(:user).permit(:hackatime_extension_text_type)
end
end

View file

@ -0,0 +1,18 @@
class Settings::AdminController < Settings::BaseController
before_action :require_admin_section_access
def show
render_settings_page(
active_section: "admin",
settings_update_path: my_settings_profile_path
)
end
private
def require_admin_section_access
unless current_user.admin_level.in?(%w[admin superadmin])
redirect_to my_settings_profile_path, alert: "You are not authorized to access this page"
end
end
end

View file

@ -0,0 +1,8 @@
class Settings::BadgesController < Settings::BaseController
def show
render_settings_page(
active_section: "badges",
settings_update_path: my_settings_profile_path
)
end
end

View file

@ -0,0 +1,229 @@
class Settings::BaseController < InertiaController
layout "inertia"
include ActionView::Helpers::DateHelper
include ActionView::Helpers::NumberHelper
before_action :set_user
before_action :require_current_user
before_action :prepare_settings_page
private
def render_settings_page(active_section:, settings_update_path:, status: :ok)
render inertia: settings_component_for(active_section), props: settings_page_props(
active_section: active_section,
settings_update_path: settings_update_path
), status: status
end
def settings_component_for(active_section)
{
"profile" => "Users/Settings/Profile",
"integrations" => "Users/Settings/Integrations",
"access" => "Users/Settings/Access",
"badges" => "Users/Settings/Badges",
"data" => "Users/Settings/Data",
"admin" => "Users/Settings/Admin"
}.fetch(active_section.to_s, "Users/Settings/Profile")
end
def prepare_settings_page
@is_own_settings = is_own_settings?
@can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write")
@enabled_sailors_logs = SailorsLogNotificationPreference.where(
slack_uid: @user.slack_uid,
enabled: true,
).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS)
@heartbeats_migration_jobs = @user.data_migration_jobs
@projects = @user.project_repo_mappings.distinct.pluck(:project_name)
@work_time_stats_base_url = @user.slack_uid.present? ? "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/" : nil
@work_time_stats_url = if @work_time_stats_base_url.present?
"#{@work_time_stats_base_url}#{@projects.first || 'example'}"
end
@general_badge_url = GithubReadmeStats.new(@user.id, "darcula").generate_badge_url
@latest_api_key_token = @user.api_keys.last&.token
@mirrors = current_user.wakatime_mirrors.order(created_at: :desc)
end
def settings_page_props(active_section:, settings_update_path:)
heartbeats_last_7_days = @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count
channel_ids = @enabled_sailors_logs.pluck(:slack_channel_id)
{
active_section: active_section,
section_paths: {
profile: my_settings_profile_path,
integrations: my_settings_integrations_path,
access: my_settings_access_path,
badges: my_settings_badges_path,
data: my_settings_data_path,
admin: my_settings_admin_path
},
page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"),
heading: (@is_own_settings ? "Settings" : "Settings for #{@user.display_name}"),
subheading: "Manage your profile, integrations, API access, and data tools.",
settings_update_path: settings_update_path,
username_max_length: User::USERNAME_MAX_LENGTH,
user: {
id: @user.id,
display_name: @user.display_name,
timezone: @user.timezone,
country_code: @user.country_code,
username: @user.username,
uses_slack_status: @user.uses_slack_status,
hackatime_extension_text_type: @user.hackatime_extension_text_type,
allow_public_stats_lookup: @user.allow_public_stats_lookup,
trust_level: @user.trust_level,
can_request_deletion: @user.can_request_deletion?,
github_uid: @user.github_uid,
github_username: @user.github_username,
slack_uid: @user.slack_uid
},
paths: {
settings_path: settings_update_path,
wakatime_setup_path: my_wakatime_setup_path,
slack_auth_path: slack_auth_path,
github_auth_path: github_auth_path,
github_unlink_path: github_unlink_path,
add_email_path: add_email_auth_path,
unlink_email_path: unlink_email_auth_path,
rotate_api_key_path: my_settings_rotate_api_key_path,
migrate_heartbeats_path: my_settings_migrate_heartbeats_path,
export_all_heartbeats_path: export_my_heartbeats_path(format: :json, all_data: "true"),
export_range_heartbeats_path: export_my_heartbeats_path(format: :json),
import_heartbeats_path: import_my_heartbeats_path,
create_deletion_path: create_deletion_path,
user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user)
},
options: {
countries: ISO3166::Country.all.map { |country|
{
label: country.common_name,
value: country.alpha2
}
}.sort_by { |country| country[:label] },
timezones: TZInfo::Timezone.all_identifiers.sort.map { |timezone|
{ label: timezone, value: timezone }
},
extension_text_types: User.hackatime_extension_text_types.keys.map { |key|
{
label: key.humanize,
value: key
}
},
badge_themes: GithubReadmeStats.themes
},
slack: {
can_enable_status: @can_enable_slack_status,
notification_channels: channel_ids.map { |channel_id|
{
id: channel_id,
label: "##{channel_id}",
url: "https://hackclub.slack.com/archives/#{channel_id}"
}
}
},
github: {
connected: @user.github_uid.present?,
username: @user.github_username,
profile_url: (@user.github_username.present? ? "https://github.com/#{@user.github_username}" : nil)
},
emails: @user.email_addresses.map { |email|
{
email: email.email,
source: email.source&.humanize || "Unknown",
can_unlink: @user.can_delete_email_address?(email)
}
},
badges: {
general_badge_url: @general_badge_url,
project_badge_url: @work_time_stats_url,
project_badge_base_url: @work_time_stats_base_url,
projects: @projects,
profile_url: (@user.username.present? ? "https://hackati.me/#{@user.username}" : nil),
markscribe_template: '{{ wakatimeDoubleCategoryBar "Languages:" wakatimeData.Languages "Projects:" wakatimeData.Projects 5 }}',
markscribe_reference_url: "https://github.com/taciturnaxolotl/markscribe#your-wakatime-languages-formated-as-a-bar",
markscribe_preview_image_url: "https://cdn.fluff.pw/slackcdn/524e293aa09bc5f9115c0c29c18fb4bc.png"
},
config_file: {
content: generated_wakatime_config(@latest_api_key_token),
has_api_key: @latest_api_key_token.present?,
empty_message: "No API key is available yet. Migrate heartbeats or rotate your API key to generate one.",
api_key: @latest_api_key_token,
api_url: "https://#{request.host_with_port}/api/hackatime/v1"
},
migration: {
jobs: @heartbeats_migration_jobs.map { |job|
{
id: job.id,
status: job.status
}
}
},
data_export: {
total_heartbeats: number_with_delimiter(@user.heartbeats.count),
total_coding_time: @user.heartbeats.duration_simple,
heartbeats_last_7_days: number_with_delimiter(heartbeats_last_7_days),
is_restricted: (@user.trust_level == "red")
},
admin_tools: {
visible: current_user.admin_level.in?(%w[admin superadmin]),
mirrors: @mirrors.map { |mirror|
{
id: mirror.id,
endpoint_url: mirror.endpoint_url,
last_synced_ago: (mirror.last_synced_at ? "#{time_ago_in_words(mirror.last_synced_at)} ago" : "Never"),
destroy_path: user_wakatime_mirror_path(current_user, mirror)
}
}
},
ui: {
show_dev_import: Rails.env.development?
},
errors: {
full_messages: @user.errors.full_messages,
username: @user.errors[:username]
}
}
end
def generated_wakatime_config(api_key)
return nil if api_key.blank?
<<~CFG
# put this in your ~/.wakatime.cfg file
[settings]
api_url = https://#{request.host_with_port}/api/hackatime/v1
api_key = #{api_key}
heartbeat_rate_limit_seconds = 30
# any other wakatime configs you want to add: https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#ini-config-file
CFG
end
def set_user
@user = if params["id"].present? && params["id"] != "my"
User.find(params["id"])
else
current_user
end
redirect_to root_path, alert: "You need to log in!" if @user.nil?
end
def require_current_user
unless @user == current_user
redirect_to root_path, alert: "You are not authorized to access this page"
end
end
def is_own_settings?
params["id"] == "my" || params["id"]&.blank?
end
end

View file

@ -0,0 +1,20 @@
class Settings::DataController < Settings::BaseController
def show
render_data
end
def migrate_heartbeats
MigrateUserFromHackatimeJob.perform_later(@user.id)
redirect_to my_settings_data_path, notice: "Heartbeats & api keys migration started"
end
private
def render_data(status: :ok)
render_settings_page(
active_section: "data",
settings_update_path: my_settings_profile_path,
status: status
)
end
end

View file

@ -0,0 +1,30 @@
class Settings::IntegrationsController < Settings::BaseController
def show
render_integrations
end
def update
if @user.update(integrations_params)
@user.update_slack_status if @user.uses_slack_status?
PosthogService.capture(@user, "settings_updated", { fields: integrations_params.keys })
redirect_to my_settings_integrations_path, notice: "Settings updated successfully"
else
flash.now[:error] = @user.errors.full_messages.to_sentence.presence || "Failed to update settings"
render_integrations(status: :unprocessable_entity)
end
end
private
def render_integrations(status: :ok)
render_settings_page(
active_section: "integrations",
settings_update_path: my_settings_integrations_path,
status: status
)
end
def integrations_params
params.require(:user).permit(:uses_slack_status)
end
end

View file

@ -0,0 +1,34 @@
class Settings::ProfileController < Settings::BaseController
def show
render_profile
end
def update
if @user.update(profile_params)
PosthogService.capture(@user, "settings_updated", { fields: profile_params.keys })
redirect_to my_settings_profile_path, notice: "Settings updated successfully"
else
flash.now[:error] = @user.errors.full_messages.to_sentence.presence || "Failed to update settings"
render_profile(status: :unprocessable_entity)
end
end
private
def render_profile(status: :ok)
render_settings_page(
active_section: "profile",
settings_update_path: my_settings_profile_path,
status: status
)
end
def profile_params
params.require(:user).permit(
:timezone,
:country_code,
:allow_public_stats_lookup,
:username,
)
end
end

View file

@ -1,58 +1,9 @@
class UsersController < InertiaController
layout :resolve_layout
layout "inertia", only: %i[wakatime_setup wakatime_setup_step_2 wakatime_setup_step_3 wakatime_setup_step_4]
include ActionView::Helpers::NumberHelper
before_action :set_user
before_action :require_current_user, except: [ :update_trust_level ]
before_action :ensure_current_user_for_setup, only: %i[wakatime_setup wakatime_setup_step_2 wakatime_setup_step_3 wakatime_setup_step_4]
before_action :require_admin, only: [ :update_trust_level ]
def edit
prepare_settings_page
end
def update
# Handle regular user settings updates
if params[:user].present?
if @user.update(user_params)
if @user.uses_slack_status?
@user.update_slack_status
end
PosthogService.capture(@user, "settings_updated", { fields: user_params.keys })
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Settings updated successfully"
else
flash.now[:error] = @user.errors.full_messages.to_sentence.presence || "Failed to update settings"
prepare_settings_page
render :edit, status: :unprocessable_entity
end
else
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Settings updated successfully!"
end
end
def migrate_heartbeats
MigrateUserFromHackatimeJob.perform_later(@user.id)
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
notice: "Heartbeats & api keys migration started"
end
def rotate_api_key
@user.api_keys.transaction do
@user.api_keys.destroy_all
new_api_key = @user.api_keys.create!(name: "Hackatime key")
PosthogService.capture(@user, "api_key_rotated")
render json: { token: new_api_key.token }, status: :ok
end
rescue => e
Rails.logger.error("error rotate #{e.class.name} #{e.message}")
render json: { error: "cant rotate" }, status: :unprocessable_entity
end
def wakatime_setup
api_key = current_user&.api_keys&.last
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
@ -134,10 +85,8 @@ class UsersController < InertiaController
private
def resolve_layout
return "application" if %w[edit update migrate_heartbeats].include?(action_name)
"inertia"
def ensure_current_user_for_setup
redirect_to root_path, alert: "You need to log in!" if current_user.nil?
end
def require_admin
@ -159,44 +108,4 @@ class UsersController < InertiaController
:mac_linux
end
def prepare_settings_page
@is_own_settings = is_own_settings?
@can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write")
@enabled_sailors_logs = SailorsLogNotificationPreference.where(
slack_uid: @user.slack_uid,
enabled: true,
).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS)
@heartbeats_migration_jobs = @user.data_migration_jobs
@projects = @user.project_repo_mappings.distinct.pluck(:project_name)
@work_time_stats_url = "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/#{@projects.first || 'example'}"
end
def set_user
@user = if params["id"].present?
User.find(params["id"])
else
current_user
end
redirect_to root_path, alert: "You need to log in!" if @user.nil?
end
def is_own_settings?
@is_own_settings ||= params["id"] == "my" || params["id"]&.blank?
end
def user_params
params.require(:user).permit(
:uses_slack_status,
:hackatime_extension_text_type,
:timezone,
:country_code,
:allow_public_stats_lookup,
:username,
)
end
end

View file

@ -36,11 +36,5 @@ createInertiaApp({
form: {
forceIndicesArrayFormatInFormData: false,
},
future: {
useScriptElementForInitialPage: true,
useDataInertiaHeadAttribute: true,
useDialogForErrorModal: true,
preserveEqualProps: true,
},
},
})

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { router } from "@inertiajs/svelte";
import { usePoll } from "@inertiajs/svelte";
import { Link, router, usePoll } from "@inertiajs/svelte";
import type { Snippet } from "svelte";
import { onMount, onDestroy } from "svelte";
import plur from "plur";
@ -72,7 +72,7 @@
stop_impersonating_path: string;
};
let { layout, children }: { layout: LayoutProps; children?: () => unknown } =
let { layout, children }: { layout: LayoutProps; children?: Snippet } =
$props();
const isBrowser =
@ -359,10 +359,10 @@
</div>
{:else}
<div>
<a
<Link
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
>Login</Link
>
</div>
{/if}
@ -370,16 +370,20 @@
<nav class="space-y-1">
{#each layout.nav.links as link}
{#if link.action === "logout"}
<a
<Link
href="#"
type="button"
onclick={openLogout}
class={`${navLinkClass(false)} cursor-pointer`}>Logout</a
onclick={(event) => {
event.preventDefault();
openLogout();
}}
class={`${navLinkClass(false)} cursor-pointer`}>Logout</Link
>
{:else}
<a
<Link
href={link.href}
onclick={handleNavLinkClick}
class={navLinkClass(link.active)}>{link.label}</a
class={navLinkClass(link.active)}>{link.label}</Link
>
{/if}
{/each}
@ -387,7 +391,7 @@
{#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
<Link
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} dev-tool"
@ -400,11 +404,11 @@
{link.badge}
</span>
{/if}
</a>
</Link>
{/each}
{#each layout.nav.admin_links as link}
<a
<Link
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} admin-tool"
@ -417,11 +421,11 @@
{link.badge}
</span>
{/if}
</a>
</Link>
{/each}
{#each layout.nav.viewer_links as link}
<a
<Link
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} viewer-tool"
@ -434,11 +438,11 @@
{link.badge}
</span>
{/if}
</a>
</Link>
{/each}
{#each layout.nav.superadmin_links as link}
<a
<Link
href={link.href}
onclick={handleNavLinkClick}
class="{navLinkClass(link.active)} superadmin-tool"
@ -451,7 +455,7 @@
{link.badge}
</span>
{/if}
</a>
</Link>
{/each}
</div>
{/if}
@ -473,10 +477,10 @@
<p
class="brightness-60 hover:brightness-100 transition-all duration-200"
>
Using Inertia. Build <a
Using Inertia. Build <Link
href={layout.footer.commit_link}
class="text-inherit underline opacity-80 hover:opacity-100 transition-opacity duration-200"
>{layout.footer.git_version}</a
>{layout.footer.git_version}</Link
>
from {layout.footer.server_start_time_ago} ago.
{plur("heartbeat", layout.footer.heartbeat_recent_count)}
@ -488,11 +492,11 @@
.requests_per_second})
</p>
{#if layout.show_stop_impersonating}
<a
<Link
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
>Stop impersonating</Link
>
{/if}
</div>
@ -553,13 +557,13 @@
/>
{/if}
{#if user.slack_uid}
<a
<Link
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>
</Link>
{:else}
<span class="text-white text-sm"
>{user.display_name || `User ${user.id}`}</span
@ -570,21 +574,21 @@
<div class="text-xs text-muted ml-8">
working on
{#if user.active_project.repo_url}
<a
<Link
href={user.active_project.repo_url}
target="_blank"
class="text-accent hover:text-cyan-400 transition-colors"
>
{user.active_project.name}
</a>
</Link>
{:else}
{user.active_project.name}
{/if}
{#if visualizeGitUrl(user.active_project.repo_url)}
<a
<Link
href={visualizeGitUrl(user.active_project.repo_url)}
target="_blank"
class="ml-1">🌌</a
class="ml-1">🌌</Link
>
{/if}
</div>

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
let {
popular_editors,
all_editors,
@ -25,7 +27,7 @@
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<a
<Link
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"
>
@ -36,9 +38,9 @@
<p class="text-sm text-muted text-center mt-1">
Set up in under a minute
</p>
</a>
</Link>
<a
<Link
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"
>
@ -47,9 +49,9 @@
</svg>
<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>
</Link>
<a
<Link
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"
>
@ -58,9 +60,9 @@
</svg>
<h3 class="font-semibold text-surface-content">API Docs</h3>
<p class="text-sm text-muted text-center mt-1">Interactive reference</p>
</a>
</Link>
<a
<Link
href="/docs/oauth/oauth-apps"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
@ -69,7 +71,7 @@
</svg>
<h3 class="font-semibold text-surface-content">OAuth Apps</h3>
<p class="text-sm text-muted text-center mt-1">Build integrations</p>
</a>
</Link>
</div>
<h2 class="text-xl font-semibold text-surface-content mb-4">
@ -77,7 +79,7 @@
</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
<Link
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"
>
@ -87,7 +89,7 @@
class="w-10 h-10 mb-2"
/>
<span class="text-sm text-surface-content">{name}</span>
</a>
</Link>
{/each}
</div>
@ -116,7 +118,7 @@
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
<Link
href={`/docs/editors/${slug}`}
class="flex flex-col items-center p-2 rounded hover:bg-surface-200 transition-colors"
>
@ -128,7 +130,7 @@
<span class="text-xs text-surface-content text-center leading-tight"
>{name}</span
>
</a>
</Link>
{/each}
</div>
</details>
@ -136,16 +138,16 @@
<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
<Link
href="https://hackclub.slack.com/archives/C07MQ845X1F"
target="_blank"
class="text-primary hover:underline">#hackatime-v2</a
class="text-primary hover:underline">#hackatime-v2</Link
>
on Slack or
<a
<Link
href="https://github.com/hackclub/hackatime/issues"
target="_blank"
class="text-primary hover:underline">open an issue</a
class="text-primary hover:underline">open an issue</Link
>
on GitHub.
</p>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { inertia } from "@inertiajs/svelte";
import { Link } from "@inertiajs/svelte";
let {
doc_path,
@ -36,10 +36,8 @@
<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
<Link href={crumb.href} class="text-secondary hover:text-primary"
>{crumb.name}</Link
>
{:else}
<span class="text-secondary">{crumb.name}</span>
@ -98,7 +96,7 @@
class="flex items-center justify-center gap-2 py-6 text-sm text-secondary/70"
>
<span>Found an issue with this page?</span>
<a
<Link
href={edit_url}
target="_blank"
class="inline-flex items-center gap-1 text-primary hover:text-red transition-colors font-medium"
@ -113,7 +111,7 @@
/></svg
>
Edit on GitHub
</a>
</Link>
</div>
</div>
</div>

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
let {
status_code = 404,
title = "Page Not Found",
@ -20,12 +22,12 @@
<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
<Link
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>
</Link>
</div>
</div>
</div>

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
const EXTENSIONS = [
{
name: "Hackatime Desktop",
@ -44,7 +46,7 @@
</p>
<div class="grid grid-cols-2 gap-3 mt-auto">
<a
<Link
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"
@ -64,15 +66,15 @@
points="7 10 12 15 17 10"
/><line x1="12" x2="12" y1="15" y2="3" /></svg
>
</a>
</Link>
<a
<Link
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>
</Link>
</div>
</div>
{/each}

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
type HomeStats = { seconds_tracked?: number; users_tracked?: number };
let {
@ -68,17 +70,17 @@
<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>
<Link href="#stats" class="hover:text-white transition-colors">Stats</Link>
<Link href="#editors" class="hover:text-white transition-colors">Editors</Link>
<Link href="/docs" class="hover:text-white transition-colors">Developers</Link>
</div>
<div class="min-w-[140px] flex justify-end">
<a
<Link
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>
</Link>
</div>
</nav>
@ -109,16 +111,16 @@
see it!
</p>
{#if show_dev_tool && dev_magic_link}
<a
<Link
href={dev_magic_link}
class="text-xs text-secondary underline hover:text-white"
>Dev: Open Link</a
>Dev: Open Link</Link
>
{/if}
</div>
{:else}
<!-- Primary Auth Buttons -->
<a
<Link
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"
@ -146,9 +148,9 @@
/>
{/if}
<span>Sign in with Hack Club</span>
</a>
</Link>
<a
<Link
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"
>
@ -158,7 +160,7 @@
/></svg
>
<span>Sign in with Slack</span>
</a>
</Link>
<!-- Divider -->
<div class="flex items-center gap-4 py-1">
@ -230,7 +232,7 @@
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
<Link
href={`/docs/editors/${editor.slug}`}
class="group flex flex-col items-center gap-2 hover:-translate-y-1 transition-transform"
>
@ -243,7 +245,7 @@
class="text-[10px] uppercase tracking-wider opacity-0 group-hover:opacity-100 transition-opacity absolute -bottom-5 text-secondary"
>{editor.name}</span
>
</a>
</Link>
{/each}
</div>
<div class="mt-8 text-sm text-secondary/60">

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import type { ActivityGraphData } from "../../../types/index";
let { data }: { data: ActivityGraphData } = $props();
@ -38,7 +39,7 @@
<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
<Link
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,
@ -49,11 +50,11 @@
data-duration={durationInWords(seconds)}
>
&nbsp;
</a>
</Link>
{/each}
</div>
<p class="super">
Calculated in
<a href={data.timezone_settings_path}>{data.timezone_label}</a>
<Link href={data.timezone_settings_path}>{data.timezone_label}</Link>
</p>
</div>

View file

@ -1,3 +1,7 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
</script>
<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>
@ -5,7 +9,7 @@
</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-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 <Link href="https://hackclub.slack.com/team/U091HC53CE8" target="_blank" class="underline">Fraud Department</Link> 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

@ -1,4 +1,6 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
let { github_auth_path }: { github_auth_path: string } = $props();
</script>
@ -21,10 +23,10 @@
working on, and qualify for leaderboards!</span
>
</div>
<a
<Link
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 text-center w-full md:w-fit"
data-turbo="false">Connect GitHub</a
data-turbo="false">Connect GitHub</Link
>
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import SocialProofUsers from "./SocialProofUsers.svelte";
type SocialProofUser = { display_name: string; avatar_url: string };
@ -21,10 +22,10 @@
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
<Link
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
>Let's setup Hackatime! Click me :D</Link
>
<SocialProofUsers
users={ssp_users_recent}

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import { onMount } from "svelte";
import SettingsShell from "./Shell.svelte";
import type { AccessPageProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
settings_update_path,
user,
options,
paths,
config_file,
errors,
admin_tools,
}: AccessPageProps = $props();
let csrfToken = $state("");
let rotatingApiKey = $state(false);
let rotatedApiKey = $state("");
let rotatedApiKeyError = $state("");
let apiKeyCopied = $state(false);
const rotateApiKey = async () => {
if (rotatingApiKey || typeof window === "undefined") return;
const confirmed = window.confirm(
"Rotate your API key now? This immediately invalidates the current key.",
);
if (!confirmed) return;
rotatingApiKey = true;
rotatedApiKey = "";
rotatedApiKeyError = "";
apiKeyCopied = false;
try {
const response = await fetch(paths.rotate_api_key_path, {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRF-Token": csrfToken,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const body = await response.json();
if (!response.ok || !body.token) {
throw new Error(body.error || "Unable to rotate API key.");
}
rotatedApiKey = body.token;
} catch (error) {
rotatedApiKeyError =
error instanceof Error ? error.message : "Unable to rotate API key.";
} finally {
rotatingApiKey = false;
}
};
const copyApiKey = async () => {
if (!rotatedApiKey || typeof navigator === "undefined") return;
await navigator.clipboard.writeText(rotatedApiKey);
apiKeyCopied = true;
};
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
<div class="space-y-8">
<section>
<h2 class="text-xl font-semibold text-surface-content">Time Tracking Setup</h2>
<p class="mt-1 text-sm text-muted">
Use the setup guide if you are configuring a new editor or device.
</p>
<Link
href={paths.wakatime_setup_path}
class="mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Open setup guide
</Link>
</section>
<section id="user_hackatime_extension">
<h2 class="text-xl font-semibold text-surface-content">Extension Display</h2>
<p class="mt-1 text-sm text-muted">
Choose how coding time appears in the extension status text.
</p>
<form method="post" action={settings_update_path} class="mt-4 space-y-4">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="extension_type" class="mb-2 block text-sm text-surface-content">
Display style
</label>
<select
id="extension_type"
name="user[hackatime_extension_text_type]"
value={user.hackatime_extension_text_type}
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
>
{#each options.extension_text_types as textType}
<option value={textType.value}>{textType.label}</option>
{/each}
</select>
</div>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Save extension settings
</button>
</form>
</section>
<section id="user_api_key">
<h2 class="text-xl font-semibold text-surface-content">API Key</h2>
<p class="mt-1 text-sm text-muted">
Rotate your API key if you think it has been exposed.
</p>
<button
type="button"
class="mt-4 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
onclick={rotateApiKey}
disabled={rotatingApiKey}
>
{rotatingApiKey ? "Rotating..." : "Rotate API key"}
</button>
{#if rotatedApiKeyError}
<p class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red-200">
{rotatedApiKeyError}
</p>
{/if}
{#if rotatedApiKey}
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-3">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
New API key
</p>
<code class="mt-2 block break-all text-sm text-surface-content">{rotatedApiKey}</code>
<button
type="button"
class="mt-3 rounded-md border border-surface-200 bg-surface-100 px-3 py-1.5 text-xs font-semibold text-surface-content transition-colors hover:bg-surface-200"
onclick={copyApiKey}
>
{apiKeyCopied ? "Copied" : "Copy key"}
</button>
</div>
{/if}
</section>
<section id="user_config_file">
<h2 class="text-xl font-semibold text-surface-content">WakaTime Config File</h2>
<p class="mt-1 text-sm text-muted">
Copy this into your <code class="rounded bg-darker px-1 py-0.5 text-xs">~/.wakatime.cfg</code> file.
</p>
{#if config_file.has_api_key && config_file.content}
<pre class="mt-4 overflow-x-auto rounded-md border border-surface-200 bg-darker p-4 text-xs text-surface-content">{config_file.content}</pre>
{:else}
<p class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
{config_file.empty_message}
</p>
{/if}
</section>
</div>
</SettingsShell>

View file

@ -0,0 +1,117 @@
<script lang="ts">
import { onMount } from "svelte";
import SettingsShell from "./Shell.svelte";
import type { AdminPageProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
admin_tools,
paths,
errors,
}: AdminPageProps = $props();
let csrfToken = $state("");
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
{#if admin_tools.visible}
<div class="space-y-8">
<section id="wakatime_mirror">
<h2 class="text-xl font-semibold text-surface-content">WakaTime Mirrors</h2>
<p class="mt-1 text-sm text-muted">
Mirror heartbeats to external WakaTime-compatible endpoints.
</p>
{#if admin_tools.mirrors.length > 0}
<div class="mt-4 space-y-2">
{#each admin_tools.mirrors as mirror}
<div class="rounded-md border border-surface-200 bg-darker p-3">
<p class="text-sm font-semibold text-surface-content">
{mirror.endpoint_url}
</p>
<p class="mt-1 text-xs text-muted">Last synced: {mirror.last_synced_ago}</p>
<form
method="post"
action={mirror.destroy_path}
class="mt-3"
onsubmit={(event) => {
if (!window.confirm("Delete this mirror endpoint?")) {
event.preventDefault();
}
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<button
type="submit"
class="rounded-md border border-surface-200 bg-surface-100 px-3 py-1.5 text-xs font-semibold text-surface-content transition-colors hover:bg-surface-200"
>
Delete
</button>
</form>
</div>
{/each}
</div>
{/if}
<form method="post" action={paths.user_wakatime_mirrors_path} class="mt-5 space-y-3">
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="endpoint_url" class="mb-2 block text-sm text-surface-content">
Endpoint URL
</label>
<input
id="endpoint_url"
type="url"
name="wakatime_mirror[endpoint_url]"
value="https://wakatime.com/api/v1"
required
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
<div>
<label for="mirror_key" class="mb-2 block text-sm text-surface-content">
WakaTime API Key
</label>
<input
id="mirror_key"
type="password"
name="wakatime_mirror[encrypted_api_key]"
required
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Add mirror
</button>
</form>
</section>
</div>
{:else}
<p class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
You are not authorized to access this section.
</p>
{/if}
</SettingsShell>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import SettingsShell from "./Shell.svelte";
import type { BadgesPageProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
options,
badges,
errors,
admin_tools,
}: BadgesPageProps = $props();
const defaultTheme = (themes: string[]) =>
themes.includes("darcula") ? "darcula" : themes[0] || "default";
let selectedTheme = $state("default");
let selectedProject = $state("");
$effect(() => {
if (options.badge_themes.length > 0 && !options.badge_themes.includes(selectedTheme)) {
selectedTheme = defaultTheme(options.badge_themes);
}
});
$effect(() => {
if (badges.projects.length === 0) {
selectedProject = "";
return;
}
if (!badges.projects.includes(selectedProject)) {
selectedProject = badges.projects[0];
}
});
const badgeUrl = () => {
const url = new URL(badges.general_badge_url);
url.searchParams.set("theme", selectedTheme);
return url.toString();
};
const projectBadgeUrl = () => {
if (!badges.project_badge_base_url || !selectedProject) return "";
return `${badges.project_badge_base_url}${encodeURIComponent(selectedProject)}`;
};
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
<div class="space-y-8">
<section id="user_stats_badges">
<h2 class="text-xl font-semibold text-surface-content">Stats Badges</h2>
<p class="mt-1 text-sm text-muted">
Generate links for profile badges that display your coding stats.
</p>
<div class="mt-4 space-y-4">
<div>
<label for="badge_theme" class="mb-2 block text-sm text-surface-content">
Theme
</label>
<select
id="badge_theme"
bind:value={selectedTheme}
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
>
{#each options.badge_themes as theme}
<option value={theme}>{theme}</option>
{/each}
</select>
</div>
<div class="rounded-md border border-surface-200 bg-darker p-4">
<img src={badgeUrl()} alt="General coding stats badge preview" class="max-w-full rounded" />
<pre class="mt-3 overflow-x-auto text-xs text-surface-content">{badgeUrl()}</pre>
</div>
</div>
{#if badges.projects.length > 0 && badges.project_badge_base_url}
<div class="mt-6 border-t border-surface-200 pt-6">
<label for="badge_project" class="mb-2 block text-sm text-surface-content">
Project
</label>
<select
id="badge_project"
bind:value={selectedProject}
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
>
{#each badges.projects as project}
<option value={project}>{project}</option>
{/each}
</select>
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4">
<img
src={projectBadgeUrl()}
alt="Project stats badge preview"
class="max-w-full rounded"
/>
<pre class="mt-3 overflow-x-auto text-xs text-surface-content">{projectBadgeUrl()}</pre>
</div>
</div>
{/if}
</section>
<section id="user_markscribe">
<h2 class="text-xl font-semibold text-surface-content">Markscribe Template</h2>
<p class="mt-1 text-sm text-muted">
Use this snippet with markscribe to include your coding stats in a
README.
</p>
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4">
<pre class="overflow-x-auto text-sm text-surface-content">{badges.markscribe_template}</pre>
</div>
<p class="mt-3 text-sm text-muted">
Reference:
<Link
href={badges.markscribe_reference_url}
target="_blank"
class="text-primary underline"
>
markscribe template docs
</Link>
</p>
<img
src={badges.markscribe_preview_image_url}
alt="Example markscribe output"
class="mt-4 w-full max-w-3xl rounded-md border border-surface-200"
/>
</section>
</div>
</SettingsShell>

View file

@ -0,0 +1,201 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import { onMount } from "svelte";
import SettingsShell from "./Shell.svelte";
import type { DataPageProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
user,
paths,
migration,
data_export,
ui,
errors,
admin_tools,
}: DataPageProps = $props();
let csrfToken = $state("");
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
<div class="space-y-8">
<section id="user_migration_assistant">
<h2 class="text-xl font-semibold text-surface-content">Migration Assistant</h2>
<p class="mt-1 text-sm text-muted">
Queue migration of heartbeats and API keys from legacy Hackatime.
</p>
<form method="post" action={paths.migrate_heartbeats_path} class="mt-4">
<input type="hidden" name="authenticity_token" value={csrfToken} />
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Start migration
</button>
</form>
{#if migration.jobs.length > 0}
<div class="mt-4 space-y-2">
{#each migration.jobs as job}
<div class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content">
Job {job.id}: {job.status}
</div>
{/each}
</div>
{/if}
</section>
<section id="download_user_data">
<h2 class="text-xl font-semibold text-surface-content">Download Data</h2>
{#if data_export.is_restricted}
<p class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red-200">
Data export is currently restricted for this account.
</p>
{:else}
<p class="mt-1 text-sm text-muted">
Download your coding history as JSON for backups or analysis.
</p>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">Total heartbeats</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_heartbeats}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">Total coding time</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_coding_time}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">Last 7 days</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.heartbeats_last_7_days}
</p>
</div>
</div>
<div class="mt-4 space-y-3">
<Link
href={paths.export_all_heartbeats_path}
class="inline-flex rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Export all heartbeats
</Link>
<form
method="get"
action={paths.export_range_heartbeats_path}
class="grid grid-cols-1 gap-3 rounded-md border border-surface-200 bg-darker p-4 sm:grid-cols-3"
>
<input
type="date"
name="start_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<input
type="date"
name="end_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<button
type="submit"
class="rounded-md border border-surface-200 bg-surface-100 px-4 py-2 text-sm font-semibold text-surface-content transition-colors hover:bg-surface-200"
>
Export date range
</button>
</form>
</div>
{#if ui.show_dev_import}
<form
method="post"
action={paths.import_heartbeats_path}
enctype="multipart/form-data"
class="mt-4 rounded-md border border-surface-200 bg-darker p-4"
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<label class="mb-2 block text-sm text-surface-content" for="heartbeat_file">
Import heartbeats (development only)
</label>
<input
id="heartbeat_file"
type="file"
name="heartbeat_file"
accept=".json,application/json"
required
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content"
/>
<button
type="submit"
class="mt-3 rounded-md border border-surface-200 bg-surface-100 px-4 py-2 text-sm font-semibold text-surface-content transition-colors hover:bg-surface-200"
>
Import file
</button>
</form>
{/if}
{/if}
</section>
<section id="delete_account">
<h2 class="text-xl font-semibold text-surface-content">Account Deletion</h2>
{#if user.can_request_deletion}
<p class="mt-1 text-sm text-muted">
Request permanent deletion. The account enters a waiting period
before final removal.
</p>
<form
method="post"
action={paths.create_deletion_path}
class="mt-4"
onsubmit={(event) => {
if (
!window.confirm(
"Submit account deletion request? This action starts the deletion process.",
)
) {
event.preventDefault();
}
}}
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<button
type="submit"
class="rounded-md border border-surface-200 bg-surface-100 px-4 py-2 text-sm font-semibold text-surface-content transition-colors hover:bg-surface-200"
>
Request deletion
</button>
</form>
{:else}
<p class="mt-3 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
Deletion request is unavailable for this account right now.
</p>
{/if}
</section>
</div>
</SettingsShell>

View file

@ -0,0 +1,215 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import { onMount } from "svelte";
import SettingsShell from "./Shell.svelte";
import type { IntegrationsPageProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
settings_update_path,
user,
slack,
github,
emails,
paths,
errors,
admin_tools,
}: IntegrationsPageProps = $props();
let csrfToken = $state("");
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
<div class="space-y-8">
<section id="user_slack_status">
<h2 class="text-xl font-semibold text-surface-content">Slack Status Sync</h2>
<p class="mt-1 text-sm text-muted">
Keep your Slack status updated while you are actively coding.
</p>
{#if !slack.can_enable_status}
<Link
href={paths.slack_auth_path}
class="mt-4 inline-flex rounded-md border border-surface-200 bg-surface-100 px-3 py-2 text-sm text-surface-content transition-colors hover:bg-surface-200"
>
Re-authorize with Slack
</Link>
{/if}
<form method="post" action={settings_update_path} class="mt-4 space-y-3">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<label class="flex items-center gap-3 text-sm text-surface-content">
<input type="hidden" name="user[uses_slack_status]" value="0" />
<input
type="checkbox"
name="user[uses_slack_status]"
value="1"
checked={user.uses_slack_status}
class="h-4 w-4 rounded border-surface-200 bg-darker text-primary"
/>
Update my Slack status automatically
</label>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Save Slack settings
</button>
</form>
</section>
<section id="user_slack_notifications">
<h2 class="text-xl font-semibold text-surface-content">Slack Channel Notifications</h2>
<p class="mt-1 text-sm text-muted">
Enable notifications in any channel by running
<code class="rounded bg-darker px-1 py-0.5 text-xs text-surface-content">
/sailorslog on
</code>
in that channel.
</p>
{#if slack.notification_channels.length > 0}
<ul class="mt-4 space-y-2">
{#each slack.notification_channels as channel}
<li class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content">
<Link href={channel.url} target="_blank" class="underline">{channel.label}</Link>
</li>
{/each}
</ul>
{:else}
<p class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
No channel notifications are enabled.
</p>
{/if}
</section>
<section id="user_github_account">
<h2 class="text-xl font-semibold text-surface-content">Connected GitHub Account</h2>
<p class="mt-1 text-sm text-muted">
Connect GitHub to show project links in dashboards and leaderboards.
</p>
{#if github.connected && github.username}
<div class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-3 text-sm text-surface-content">
Connected as
<Link href={github.profile_url || "#"} target="_blank" class="underline">
@{github.username}
</Link>
</div>
<div class="mt-3 flex flex-wrap gap-3">
<Link
href={paths.github_auth_path}
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Reconnect GitHub
</Link>
<form
method="post"
action={paths.github_unlink_path}
onsubmit={(event) => {
if (
!window.confirm(
"Unlink this GitHub account? GitHub-based features will stop until relinked.",
)
) {
event.preventDefault();
}
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<button
type="submit"
class="rounded-md border border-surface-200 bg-surface-100 px-4 py-2 text-sm font-semibold text-surface-content transition-colors hover:bg-surface-200"
>
Unlink GitHub
</button>
</form>
</div>
{:else}
<Link
href={paths.github_auth_path}
class="mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Connect GitHub
</Link>
{/if}
</section>
<section id="user_email_addresses">
<h2 class="text-xl font-semibold text-surface-content">Email Addresses</h2>
<p class="mt-1 text-sm text-muted">
Add or remove email addresses used for sign-in and verification.
</p>
<div class="mt-4 space-y-2">
{#if emails.length > 0}
{#each emails as email}
<div class="flex flex-wrap items-center gap-2 rounded-md border border-surface-200 bg-darker px-3 py-2">
<div class="grow text-sm text-surface-content">
<p>{email.email}</p>
<p class="text-xs text-muted">{email.source}</p>
</div>
{#if email.can_unlink}
<form method="post" action={paths.unlink_email_path}>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<input type="hidden" name="email" value={email.email} />
<button
type="submit"
class="rounded-md border border-surface-200 bg-surface-100 px-3 py-1.5 text-xs font-semibold text-surface-content transition-colors hover:bg-surface-200"
>
Unlink
</button>
</form>
{/if}
</div>
{/each}
{:else}
<p class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
No email addresses are linked.
</p>
{/if}
</div>
<form method="post" action={paths.add_email_path} class="mt-4 flex flex-col gap-3 sm:flex-row">
<input type="hidden" name="authenticity_token" value={csrfToken} />
<input
type="email"
name="email"
required
placeholder="name@example.com"
class="grow rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Add email
</button>
</form>
</section>
</div>
</SettingsShell>

View file

@ -0,0 +1,172 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import { onMount } from "svelte";
import SettingsShell from "./Shell.svelte";
import type { ProfilePageProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
settings_update_path,
username_max_length,
user,
options,
badges,
errors,
admin_tools,
}: ProfilePageProps = $props();
let csrfToken = $state("");
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
<div class="space-y-8">
<section id="user_region">
<h2 class="text-xl font-semibold text-surface-content">Region and Timezone</h2>
<p class="mt-1 text-sm text-muted">
Use your local region and timezone for accurate dashboards and
leaderboards.
</p>
<form method="post" action={settings_update_path} class="mt-4 space-y-4">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="country_code" class="mb-2 block text-sm text-surface-content">
Country
</label>
<select
id="country_code"
name="user[country_code]"
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
value={user.country_code || ""}
>
<option value="">Select a country</option>
{#each options.countries as country}
<option value={country.value}>{country.label}</option>
{/each}
</select>
</div>
<div id="user_timezone">
<label for="timezone" class="mb-2 block text-sm text-surface-content">
Timezone
</label>
<select
id="timezone"
name="user[timezone]"
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
value={user.timezone}
>
{#each options.timezones as timezone}
<option value={timezone.value}>{timezone.label}</option>
{/each}
</select>
</div>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Save region settings
</button>
</form>
</section>
<section id="user_username">
<h2 class="text-xl font-semibold text-surface-content">Username</h2>
<p class="mt-1 text-sm text-muted">
This username is used in links and public profile pages.
</p>
<form method="post" action={settings_update_path} class="mt-4 space-y-3">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="username" class="mb-2 block text-sm text-surface-content">
Username
</label>
<input
id="username"
name="user[username]"
value={user.username || ""}
maxlength={username_max_length}
placeholder="your-name"
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
{#if errors.username.length > 0}
<p class="mt-2 text-xs text-red-300">{errors.username[0]}</p>
{/if}
</div>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Save username
</button>
</form>
{#if badges.profile_url}
<p class="mt-3 text-sm text-muted">
Public profile:
<Link
href={badges.profile_url}
target="_blank"
class="text-primary underline"
>
{badges.profile_url}
</Link>
</p>
{/if}
</section>
<section id="user_privacy">
<h2 class="text-xl font-semibold text-surface-content">Privacy</h2>
<p class="mt-1 text-sm text-muted">
Control whether your coding stats can be used by public APIs.
</p>
<form method="post" action={settings_update_path} class="mt-4 space-y-3">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<label class="flex items-center gap-3 text-sm text-surface-content">
<input type="hidden" name="user[allow_public_stats_lookup]" value="0" />
<input
type="checkbox"
name="user[allow_public_stats_lookup]"
value="1"
checked={user.allow_public_stats_lookup}
class="h-4 w-4 rounded border-surface-200 bg-darker text-primary"
/>
Allow public stats lookup
</label>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Save privacy settings
</button>
</form>
</section>
</div>
</SettingsShell>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import type { Snippet } from "svelte";
import { onMount } from "svelte";
import { buildSections, sectionFromHash } from "./types";
import type { SectionPaths, SettingsCommonProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
errors,
admin_tools,
children,
}: SettingsCommonProps & { children?: Snippet } = $props();
const sections = $derived(buildSections(section_paths, admin_tools.visible));
const knownSectionIds = $derived(new Set(sections.map((section) => section.id)));
const sectionButtonClass = (sectionId: keyof SectionPaths) =>
`block w-full px-3 py-3 text-left transition-colors ${
active_section === sectionId
? "bg-surface-100 text-surface-content"
: "bg-surface text-muted hover:bg-surface-100 hover:text-surface-content"
}`;
onMount(() => {
const syncSectionFromHash = () => {
const section = sectionFromHash(window.location.hash);
if (!section || !knownSectionIds.has(section)) return;
if (section === active_section || !section_paths[section]) return;
window.location.replace(`${section_paths[section]}${window.location.hash}`);
};
syncSectionFromHash();
window.addEventListener("hashchange", syncSectionFromHash);
return () => window.removeEventListener("hashchange", syncSectionFromHash);
});
</script>
<svelte:head>
<title>{page_title}</title>
</svelte:head>
<div class="mx-auto max-w-7xl">
<header class="mb-8">
<h1 class="text-3xl font-bold text-surface-content">{heading}</h1>
<p class="mt-2 text-sm text-muted">{subheading}</p>
</header>
{#if errors.full_messages.length > 0}
<div class="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-red-200">
<p class="font-semibold">Some changes could not be saved:</p>
<ul class="mt-2 list-disc pl-5">
{#each errors.full_messages as message}
<li>{message}</li>
{/each}
</ul>
</div>
{/if}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside class="h-max lg:sticky lg:top-8">
<div class="overflow-hidden rounded-xl border border-[#4A3438] bg-surface divide-y divide-[#4A3438]">
{#each sections as section}
<Link href={section.path} class={sectionButtonClass(section.id)}>
<p class="text-sm font-semibold">{section.label}</p>
<p class="mt-1 text-xs opacity-80">{section.blurb}</p>
</Link>
{/each}
</div>
</aside>
<section class="rounded-xl border border-surface-200 bg-surface p-5 md:p-6">
{@render children?.()}
</section>
</div>
</div>

View file

@ -0,0 +1,248 @@
export type SectionId =
| "profile"
| "integrations"
| "access"
| "badges"
| "data"
| "admin";
export type SectionPaths = Record<SectionId, string>;
export type Option = {
label: string;
value: string;
};
export type UserProps = {
id: number;
display_name: string;
timezone: string;
country_code?: string | null;
username?: string | null;
uses_slack_status: boolean;
hackatime_extension_text_type: string;
allow_public_stats_lookup: boolean;
trust_level: string;
can_request_deletion: boolean;
github_uid?: string | null;
github_username?: string | null;
slack_uid?: string | null;
};
export type PathsProps = {
settings_path: string;
wakatime_setup_path: string;
slack_auth_path: string;
github_auth_path: string;
github_unlink_path: string;
add_email_path: string;
unlink_email_path: string;
rotate_api_key_path: string;
migrate_heartbeats_path: string;
export_all_heartbeats_path: string;
export_range_heartbeats_path: string;
import_heartbeats_path: string;
create_deletion_path: string;
user_wakatime_mirrors_path: string;
};
export type OptionsProps = {
countries: Option[];
timezones: Option[];
extension_text_types: Option[];
badge_themes: string[];
};
export type SlackProps = {
can_enable_status: boolean;
notification_channels: {
id: string;
label: string;
url: string;
}[];
};
export type GithubProps = {
connected: boolean;
username?: string | null;
profile_url?: string | null;
};
export type EmailProps = {
email: string;
source: string;
can_unlink: boolean;
};
export type BadgesProps = {
general_badge_url: string;
project_badge_url?: string | null;
project_badge_base_url?: string | null;
projects: string[];
profile_url?: string | null;
markscribe_template: string;
markscribe_reference_url: string;
markscribe_preview_image_url: string;
};
export type ConfigFileProps = {
content?: string | null;
has_api_key: boolean;
empty_message: string;
api_key?: string | null;
api_url: string;
};
export type MigrationProps = {
jobs: { id: string; status: string }[];
};
export type DataExportProps = {
total_heartbeats: string;
total_coding_time: string;
heartbeats_last_7_days: string;
is_restricted: boolean;
};
export type AdminToolsProps = {
visible: boolean;
mirrors: {
id: number;
endpoint_url: string;
last_synced_ago: string;
destroy_path: string;
}[];
};
export type UiProps = {
show_dev_import: boolean;
};
export type ErrorsProps = {
full_messages: string[];
username: string[];
};
export type SettingsCommonProps = {
active_section: SectionId;
section_paths: SectionPaths;
page_title: string;
heading: string;
subheading: string;
errors: ErrorsProps;
admin_tools: AdminToolsProps;
};
export type ProfilePageProps = SettingsCommonProps & {
settings_update_path: string;
username_max_length: number;
user: UserProps;
options: OptionsProps;
badges: BadgesProps;
};
export type IntegrationsPageProps = SettingsCommonProps & {
settings_update_path: string;
user: UserProps;
slack: SlackProps;
github: GithubProps;
emails: EmailProps[];
paths: PathsProps;
};
export type AccessPageProps = SettingsCommonProps & {
settings_update_path: string;
user: UserProps;
options: OptionsProps;
paths: PathsProps;
config_file: ConfigFileProps;
};
export type BadgesPageProps = SettingsCommonProps & {
options: OptionsProps;
badges: BadgesProps;
};
export type DataPageProps = SettingsCommonProps & {
user: UserProps;
paths: PathsProps;
migration: MigrationProps;
data_export: DataExportProps;
ui: UiProps;
};
export type AdminPageProps = SettingsCommonProps & {
admin_tools: AdminToolsProps;
paths: PathsProps;
};
export const buildSections = (sectionPaths: SectionPaths, adminVisible: boolean) => {
const sections = [
{
id: "profile" as SectionId,
label: "Profile",
blurb: "Username, region, timezone, and privacy.",
path: sectionPaths.profile,
},
{
id: "integrations" as SectionId,
label: "Integrations",
blurb: "Slack status, GitHub link, and email sign-in addresses.",
path: sectionPaths.integrations,
},
{
id: "access" as SectionId,
label: "Access",
blurb: "Time tracking setup, extension options, and API key access.",
path: sectionPaths.access,
},
{
id: "badges" as SectionId,
label: "Badges",
blurb: "Shareable badges and profile snippets.",
path: sectionPaths.badges,
},
{
id: "data" as SectionId,
label: "Data",
blurb: "Exports, migration jobs, and account deletion controls.",
path: sectionPaths.data,
},
];
if (adminVisible) {
sections.push({
id: "admin",
label: "Admin",
blurb: "WakaTime mirror endpoints.",
path: sectionPaths.admin,
});
}
return sections;
};
const hashSectionMap: Record<string, SectionId> = {
user_region: "profile",
user_timezone: "profile",
user_username: "profile",
user_privacy: "profile",
user_hackatime_extension: "access",
user_api_key: "access",
user_config_file: "access",
user_slack_status: "integrations",
user_slack_notifications: "integrations",
user_github_account: "integrations",
user_email_addresses: "integrations",
user_stats_badges: "badges",
user_markscribe: "badges",
user_migration_assistant: "data",
download_user_data: "data",
delete_account: "data",
wakatime_mirror: "admin",
};
export const sectionFromHash = (hash: string): SectionId | null => {
const cleanHash = hash.replace(/^#/, "");
return hashSectionMap[cleanHash] || null;
};

View file

@ -116,12 +116,12 @@
Heartbeat detected {heartbeatTimeAgo}.
</p>
<a
<Link
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>
</Link>
</div>
{/if}
</div>
@ -357,10 +357,10 @@ heartbeat_rate_limit_seconds = 30</code
{/if}
<div class="text-center">
<a
<Link
href="/my/wakatime_setup/step-2"
class="text-xs text-secondary hover:text-white transition-colors"
>Skip to next step</a
>Skip to next step</Link
>
</div>
</div>

View file

@ -30,7 +30,7 @@
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
{#each editors as editor}
<a
<Link
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">
@ -41,7 +41,7 @@
{/if}
</div>
<span class="font-medium text-white group-hover:text-primary transition-colors">{editor.name}</span>
</a>
</Link>
{/each}
</div>
</div>

View file

@ -175,11 +175,11 @@
Open VS Code, go to Extensions (squares icon), search for <strong
>WakaTime</strong
>, and click Install.
<a
<Link
href="https://marketplace.visualstudio.com/items?itemName=WakaTime.vscode-wakatime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Marketplace</a
class="text-cyan hover:underline">View on Marketplace</Link
>
</p>
</div>
@ -279,21 +279,21 @@
: "VS Code"}.
</p>
<a
<Link
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>
</Link>
</div>
{/if}
</div>
<div class="text-center">
<a
<Link
href="/my/wakatime_setup/step-4"
class="text-xs text-secondary hover:text-white transition-colors"
>Skip to finish</a
>Skip to finish</Link
>
</div>
</div>
@ -331,7 +331,7 @@
</div>
<div class="pt-2">
<a
<Link
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"
@ -342,17 +342,17 @@
/></svg
>
Watch setup tutorial
</a>
</Link>
</div>
</div>
</div>
<a
<Link
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>
</Link>
{:else if editor === "jetbrains"}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
<div class="flex items-center gap-4 mb-6">
@ -399,11 +399,11 @@
<p class="text-sm text-secondary">
Search for <b>WakaTime</b> in the marketplace and click Install.
<a
<Link
href="https://plugins.jetbrains.com/plugin/7425-wakatime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Marketplace</a
class="text-cyan hover:underline">View on Marketplace</Link
>
</p>
</div>
@ -459,12 +459,12 @@
</div>
</div>
<a
<Link
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>
</Link>
{:else if editor === "sublime"}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
<div class="flex items-center gap-4 mb-6">
@ -492,11 +492,11 @@
<p class="font-medium mb-1">Install Package Control</p>
<p class="text-sm text-secondary">
If you don't have Package Control installed, install it at
<a
<Link
href="https://packagecontrol.io/installation"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">packagecontrol.io</a
class="text-cyan hover:underline">packagecontrol.io</Link
> to set it up first.
</p>
</div>
@ -512,11 +512,11 @@
<p class="font-medium mb-1">Install WakaTime Plugin</p>
<p class="text-sm text-secondary">
Open the Command Palette (Ctrl+Shift+P on Windows/Linux, Command+Shift+P on macOS), type <b>Package Control: Install Package</b>, and press Enter. Then type <b>WakaTime</b> and press Enter to install.
<a
<Link
href="https://packagecontrol.io/packages/WakaTime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Package Control</a
class="text-cyan hover:underline">View on Package Control</Link
>
</p>
</div>
@ -566,12 +566,12 @@
</div>
</div>
<a
<Link
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>
</Link>
{: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">
@ -615,12 +615,12 @@
</div>
</div>
<a
<Link
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>
</Link>
{:else}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
<div class="mb-6">
@ -646,7 +646,7 @@
</div>
<div class="pt-4 grid grid-cols-2 gap-3">
<a
<Link
href="/docs/editors/pycharm"
class="flex items-center gap-3 bg-darkless/50 rounded-lg p-3 hover:bg-darkless transition-colors"
>
@ -656,8 +656,8 @@
class="w-6 h-6"
/>
<span class="text-sm">PyCharm</span>
</a>
<a
</Link>
<Link
href="/docs/editors/sublime-text"
class="flex items-center gap-3 bg-darkless/50 rounded-lg p-3 hover:bg-darkless transition-colors"
>
@ -667,8 +667,8 @@
class="w-6 h-6"
/>
<span class="text-sm">Sublime Text</span>
</a>
<a
</Link>
<Link
href="/docs/editors/unity"
class="flex items-center gap-3 bg-darkless/50 rounded-lg p-3 hover:bg-darkless transition-colors"
>
@ -678,25 +678,25 @@
class="w-6 h-6"
/>
<span class="text-sm">Unity</span>
</a>
<a
</Link>
<Link
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>
</Link>
</div>
</div>
</div>
<a
<Link
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>
</Link>
{/if}
</div>
</div>

View file

@ -58,14 +58,14 @@
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
<Link
href={return_url ?? "/"}
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'}"
>
{return_url ? return_button_text : "Let's get going!"}
</a>
</Link>
</div>
</div>
</div>

View file

@ -1,523 +0,0 @@
<% content_for :title do %>
<%= @is_own_settings ? 'My Settings' : "Settings | #{@user.display_name}" %>
<% end %>
<div class="max-w-6xl mx-auto p-6 space-y-6">
<header class="text-center mb-8">
<h1 class="text-4xl font-bold text-white mb-2">
<%= @is_own_settings ? 'My Settings' : "Settings for #{@user.display_name}" %>
</h1>
<p class="text-muted text-lg">Change your Hackatime experience and preferences</p>
</header>
<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="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white">Time Tracking Wizard</h2>
</div>
<p class="text-gray-300 mb-4">Get started with tracking your coding time in just a few minutes.</p>
<%= link_to 'Set up time tracking', my_wakatime_setup_path, class: 'inline-flex items-center gap-2 px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200' %>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_region">Region</h2>
</div>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch, local: false,
class: "space-y-4" do |f| %>
<div>
<%= f.label :country_code, 'Country flag', class: 'block text-sm font-medium text-gray-200 mb-2' %>
<%= f.select :country_code, ISO3166::Country.all.map { |c| [c.common_name, c.alpha2] }.sort_by(&:first), { include_blank: 'Select a country' }, { class: 'w-full px-3 py-2 h-10 bg-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary' } %>
</div>
<p class="text-xs text-secondary">Your country flag will be displayed on your profile and leaderboards.</p>
<div>
<%= f.label :timezone, 'Timezone', class: 'block text-sm font-medium text-gray-200 mb-2' %>
<%= f.select :timezone, TZInfo::Timezone.all.map(&:identifier).sort, { include_blank: @user.timezone.blank? }, { class: 'w-full px-3 py-2 h-10 bg-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary' } %>
</div>
<p class="text-xs text-secondary">This affects how your activity graph and other time-based features are displayed.</p>
<%= f.submit 'Save Settings', class: 'w-full px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_hackatime_extension">Extension Settings</h2>
</div>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch, local: false,
class: "space-y-4" do |f| %>
<div>
<%= f.label :hackatime_extension_text_type, 'Status bar text style', class: 'block text-sm font-medium text-gray-200 mb-2' %>
<%= f.select :hackatime_extension_text_type, User.hackatime_extension_text_types.keys.map { |key| [key.humanize, key] }, {}, { class: 'w-full px-3 py-2 h-10 bg-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary' } %>
</div>
<%= f.submit 'Save Settings', class: 'w-full px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_username">Username</h2>
</div>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch, local: false,
class: "space-y-4" do |f| %>
<div>
<%= f.label :username, 'Custom username', class: 'block text-sm font-medium text-gray-200 mb-2' %>
<%= f.text_field :username, class: 'w-full px-3 py-2 bg-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary', placeholder: 'HackClubber', maxlength: User::USERNAME_MAX_LENGTH %>
<% if @user.errors[:username].present? %>
<p class="mt-1 text-xs text-red-400"><%= @user.errors[:username].to_sentence %></p>
<% end %>
</div>
<p class="text-xs text-secondary">Choose a name to use in Hackatime. Only letters, numbers, "-" and "_" are allowed, max <%= User::USERNAME_MAX_LENGTH %> characters.</p>
<% if @user.username.present? %>
<p class="text-md text-green">Your profile is currently live at <%= link_to "hackati.me/#{@user.username}", "https://hackati.me/#{@user.username}", target: '_blank', class: 'underline' %></p>
<% end %>
<%= f.submit 'Save Settings', class: 'w-full px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_slack_status">Slack Integration</h2>
</div>
<div class="space-y-4">
<div>
<h3 class="text-lg font-medium text-white mb-2">Status Updates</h3>
<p class="text-gray-300 text-sm mb-3">When you're hacking on a project, Hackatime can update your Slack status so you can show it off!</p>
<% unless @can_enable_slack_status %>
<%= link_to 'Re-authorize with Slack', slack_auth_path, class: 'inline-flex items-center gap-2 px-3 py-2 bg-darkless hover:bg-dark text-gray-200 text-sm font-medium rounded transition-colors duration-200 mb-3' %>
<% end %>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch, local: false do |f| %>
<div class="flex items-center gap-3">
<%= f.check_box :uses_slack_status, class: 'w-4 h-4 text-primary border-darkless rounded focus:ring-primary bg-darkless' %>
<%= f.label :uses_slack_status, 'Update my Slack status automatically', class: 'text-sm text-gray-200' %>
</div>
<%= f.submit 'Save', class: 'mt-3 px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
</div>
<div class="border-t border-darkless pt-4">
<h3 class="text-lg font-medium text-white mb-2" id="user_slack_notifications">Channel Notifications</h3>
<% if @enabled_sailors_logs.any? %>
<p class="text-gray-300 text-sm mb-2">You have notifications enabled for the following channels:</p>
<ul class="space-y-1 mb-3">
<% @enabled_sailors_logs.each do |sl| %>
<li class="text-xs text-gray-300 px-2 py-1 bg-darkless rounded">
<%= render 'shared/slack_channel_mention', channel_id: sl.slack_channel_id %>
</li>
<% end %>
</ul>
<% else %>
<p class="text-gray-300 text-sm mb-3">You have no notifications enabled.</p>
<% end %>
<p class="text-xs text-secondary">You can enable notifications for specific channels by running <code class="px-1 py-0.5 bg-darkless rounded text-gray-200">/sailorslog on</code> in the Slack channel.</p>
</div>
</div>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_privacy">Privacy Settings</h2>
</div>
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch, local: false,
class: "space-y-4" do |f| %>
<div class="flex items-center gap-3">
<%= f.check_box :allow_public_stats_lookup, class: 'w-4 h-4 text-primary border-darkless rounded focus:ring-primary bg-darkless' %>
<%= f.label :allow_public_stats_lookup, 'Allow public stats lookup', class: 'text-sm text-gray-200' %>
</div>
<p class="text-xs text-secondary">When enabled, others can view your coding statistics through public APIs. Many Hack Club YSWS programs use this to track your progress. Disabling this can prevent you from participating in some programs.</p>
<%= f.submit 'Save Settings', class: 'w-full px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
<div class="border-t border-darkless pt-4 mt-4 space-y-3">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-white" id="delete_account">Delete Account</h3>
</div>
<% if @user.can_request_deletion? %>
<p class="text-gray-300 text-sm">Permanently delete your account and all associated data. This action cannot be undone after the 30-day grace period.</p>
<button type="button" data-controller="account-deletion" data-action="click->account-deletion#confirm" class="w-full px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer">Request Account Deletion</button>
<% else %>
<p class="text-white text-sm">Due to your account standing, you cannot request account deletion at this time. Reach out in #hackatime-v2 if this is a mistake.</p>
<% end %>
</div>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_api_key">API Key</h2>
</div>
<div class="space-y-4">
<p class="text-gray-300 text-sm">Your API key is used to authenticate requests from your code editor. If your key has been compromised, you can rotate it to generate a new one. Rotating your API key will immediately invalidate your old key. You'll need to update the key in all of your code editors and IDEs.</p>
<button type="button" data-controller="api-key-rotation" data-action="click->api-key-rotation#confirm" class="w-full px-4 py-2 bg-primary hover:bg-primary/75 text-white font-medium rounded transition-colors duration-200 cursor-pointer">Rotate API Key</button>
</div>
</div>
<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">
<h2 class="text-xl font-semibold text-white" id="user_github_account">Connected Accounts</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="text-lg font-medium text-white">GitHub Account</h3>
<p class="text-gray-300 text-sm">This is used to show your active projects on the leaderboard & current hacking activity on the dashboard.</p>
<% if @user.github_uid.present? %>
<div class="flex items-center gap-2 p-3 bg-darkless border border-darkless rounded">
<span class="text-green-400">✅</span>
<span class="text-gray-200 text-sm">Connected: <%= link_to "@#{h(@user.github_username)}", "https://github.com/#{h(@user.github_username)}", target: '_blank', class: 'text-primary hover:text-primary/80 underline' %></span>
</div>
<div class="flex items-center gap-2">
<%= link_to 'Relink GitHub Account', github_auth_path, data: { turbo: 'false' }, class: 'inline-flex items-center gap-2 px-3 py-2 bg-primary text-white hover:bg-primary/75 text-sm font-medium rounded transition-colors duration-200 cursor-pointer' %>
<%= button_to 'Unlink', github_unlink_path, method: :delete, data: { turbo_confirm: 'Are you sure you want to unlink your GitHub account? This will remove your GitHub connection and you may need to re-link to use GitHub-dependent features.' }, class: 'inline-flex items-center gap-2 px-3 py-2 bg-darkless hover:bg-darkless/50 text-white hover:text-white/80 text-sm font-medium rounded transition-colors duration-200 cursor-pointer' %>
</div>
<% else %>
<%= link_to 'Link GitHub Account', github_auth_path, data: { turbo: 'false' }, class: 'inline-flex items-center gap-2 px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
</div>
<div class="space-y-3" id="user_email_addresses">
<h3 class="text-lg font-medium text-white">Email Addresses</h3>
<p class="text-gray-300 text-sm">These are the email addresses associated with your account.</p>
<% if @user.email_addresses.any? %>
<div class="space-y-2">
<% @user.email_addresses.each do |email| %>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2 p-2 bg-darkless border border-darkless rounded grow">
<span class="text-gray-300 text-sm"><%= email.email %></span>
<span class="text-xs px-2 py-1 bg-dark text-secondary rounded">
<%= email.source&.humanize || 'Unknown' %>
</span>
</div>
<% if @user.can_delete_email_address?(email) %>
<%= form_with url: unlink_email_auth_path,
method: :delete,
class: "space-y-4" do |f| %>
<%= f.hidden_field :email, value: email.email %>
<%= f.submit 'Unlink!', class: 'w-full px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% else %>
<p class="text-secondary text-sm">No email addresses found.</p>
<% end %>
<%= form_tag add_email_auth_path, data: { turbo: false }, class: "space-y-2" do %>
<%= email_field_tag :email, nil, placeholder: 'Add another email address', required: true, class: 'w-full px-3 py-2 bg-darkless border border-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary text-sm' %>
<%= submit_tag 'Add Email', class: 'w-full px-3 py-2 bg-primary hover:bg-primary/75 text-white text-sm font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% end %>
</div>
</div>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_stats_badges">Stats Badges</h2>
</div>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-white mb-2">General Stats Badge</h3>
<p class="text-gray-300 text-sm mb-4">Show your coding stats on your GitHub profile with beautiful badges.</p>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Theme</label>
<select name="theme" id="theme-select" onchange="up1(this.value)" class="w-full px-3 py-2 h-10 bg-darkless border border-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary">
<% GithubReadmeStats.themes.each do |theme| %><option value="<%= theme %>"><%= theme.humanize %></option>
<% end %>
</select>
</div>
<% gh_badge = GithubReadmeStats.new(current_user.id, 'darcula') %>
<div class="p-4 bg-darkless border border-darkless rounded">
<img id="badge-preview" src="<%= gh_badge.generate_badge_url %>" data-url="<%= gh_badge.generate_badge_url %>" class="mb-3 rounded">
<pre id="badge-url" class="text-xs text-white bg-darker p-2 rounded overflow-x-auto"><%= gh_badge.generate_badge_url %></pre>
</div>
</div>
</div>
<% if @projects.any? && @user.slack_uid.present? %>
<div class="border-t border-darkless pt-4">
<h3 class="text-lg font-medium text-white mb-2">Project Stats Badge</h3>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-200">Project</label>
<select name="project" id="project-select" onchange="up2(this.value)" class="w-full px-3 py-2 h-10 bg-darkless border border-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary">
<% @projects.each do |project| %><option value="<%= h(project) %>"><%= h(project) %></option>
<% end %>
</select>
<div class="mt-3 p-4 bg-darkless border border-darkless rounded">
<img id="project-badge-preview" src="<%= @work_time_stats_url %>" class="mb-3 rounded">
<pre id="project-badge-url" class="text-xs text-gray-300 bg-darker p-2 rounded overflow-x-auto"><%= @work_time_stats_url %></pre>
</div>
</div>
</div>
<% end %>
</div>
<script>
function up1(theme) {
const preview = document.getElementById("badge-preview");
const url = document.getElementById("badge-url");
const baseUrl = preview.dataset.url.replace(/theme=[^&]*/, "");
const newUrl = baseUrl + (baseUrl.includes("?") ? "&" : "?") + "theme=" + theme;
preview.src = newUrl;
url.textContent = newUrl;
}
function up2(project) {
const preview = document.getElementById("project-badge-preview");
const url = document.getElementById("project-badge-url");
const baseUrl = <%== (@work_time_stats_url.gsub(@projects.first || 'example', '')).to_json %>;
const newUrl = baseUrl + project;
preview.src = newUrl;
url.textContent = newUrl;
}
</script>
</div>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 space-y-6">
<div>
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_config_file">Config File</h2>
</div>
<p class="text-gray-300 text-sm mb-4">Your Wakatime configuration file for tracking coding time.</p>
<div class="bg-darkless border border-darkless rounded p-4 overflow-x-auto">
<%= render 'wakatime_config_display' %>
</div>
<p class="text-xs text-secondary mt-2">This configuration file is automatically generated and updated when you make changes to your settings.</p>
</div>
<div class="border-t border-darkless pt-6">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="user_migration_assistant">Migration Assistant</h2>
</div>
<p class="text-gray-300 text-sm mb-4">This will migrate your heartbeats from waka.hackclub.com to this platform.</p>
<%= button_to 'Migrate heartbeats', my_settings_migrate_heartbeats_path, method: :post, class: 'w-full px-4 py-2 bg-primary text-white hover:bg-primary/75 font-medium rounded transition-colors duration-200 cursor-pointer' %>
<% if @heartbeats_migration_jobs.any? %>
<div class="mt-4 space-y-2">
<h3 class="text-sm font-medium text-white">Migration Status</h3>
<% @heartbeats_migration_jobs.each do |job| %>
<div class="p-2 bg-darkless border border-darkless rounded text-xs text-gray-300">Job ID: <%= job.id %> - Status: <%= job.status %></div>
<% end %>
</div>
<% end %>
</div>
</div>
<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">
<h2 class="text-xl font-semibold text-white" id="user_markscribe">Markscribe Templates</h2>
</div>
<p class="text-gray-300 text-sm mb-4">Use markscribe to create beautiful GitHub profile READMEs with your coding stats.</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<div class="p-4 bg-darkless border border-darkless rounded mb-4 overflow-x-auto">
<pre class="text-sm text-gray-200 whitespace-pre-wrap break-all"><code>{{ wakatimeDoubleCategoryBar "💾 Languages:" wakatimeData.Languages "💼 Projects:" wakatimeData.Projects 5 }}</code></pre>
</div>
<p class="text-gray-300 text-sm mb-2">Add this to your GitHub profile README template to display your top languages and projects.</p>
<p class="text-xs text-secondary">See the <a href="https://github.com/taciturnaxolotl/markscribe#your-wakatime-languages-formated-as-a-bar" target="_blank" class="text-primary hover:text-primary/80 underline">markscribe documentation</a> for more template options.</p>
</div>
<div>
<img src="https://cdn.fluff.pw/slackcdn/524e293aa09bc5f9115c0c29c18fb4bc.png" alt="Example of markscribe output showing coding language and project statistics" class="w-full rounded border border-darkless">
</div>
</div>
</div>
<%# This is copied from the github thingie blog, Im not good at UI so I copied :) %>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2">
<% if @user.trust_level == "red" %>
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2>
</div>
<div class="bg-red-500/20 border border-red-500 rounded-lg p-4">
<div class="flex items-center gap-2">
<span class="text-red-500 font-medium">⚠️ Export Restricted</span>
</div>
<p class="text-red-500 text-sm mt-2">Sorry, due to your account standing, you are unable to perform this action.</p>
</div>
<% else %>
<div class="flex items-center gap-3 mb-4">
<h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="text-lg font-medium text-white">Your Data Overview</h3>
<div class="grid grid-cols-1 gap-4">
<div class="bg-darkless border border-darkless rounded p-4">
<div class="text-center">
<div class="text-2xl font-bold text-primary mb-1"><%= number_with_delimiter(@user.heartbeats.count) %></div>
<div class="text-sm text-gray-300">Total Heartbeats</div>
</div>
</div>
<div class="bg-darkless border border-darkless rounded p-4">
<div class="text-center">
<div class="text-2xl font-bold text-orange mb-1"><%= number_with_delimiter(@user.heartbeats.duration_simple) %></div>
<div class="text-sm text-gray-300">Total Coding Time</div>
</div>
</div>
<div class="bg-darkless border border-darkless rounded p-4">
<div class="text-center">
<div class="text-2xl font-bold text-primary mb-1"><%= @user.heartbeats.where('time >= ?', 7.days.ago.to_f).count %></div>
<div class="text-sm text-gray-300">Heartbeats in the Last 7 Days</div>
</div>
</div>
</div>
</div>
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Export Options</h3>
<div class="bg-darkless border border-darkless rounded p-4">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<h4 class="text-white font-medium">Heartbeat Data</h4>
</div>
<p class="text-gray-300 text-sm mb-3">Export your coding activity as JSON with detailed information about each coding session.</p>
<div class="space-y-2">
<%= link_to export_my_heartbeats_path(format: :json, all_data: "true"),
class: "w-full bg-primary hover:bg-primary/75 text-white px-4 py-2 rounded font-medium transition-colors inline-flex items-center justify-center gap-2",
method: :get do %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
Export All Heartbeats
<% end %>
<button type="button" class="w-full bg-dark hover:bg-dark/75 border border-darkless text-white px-4 py-2 rounded font-medium transition-colors inline-flex items-center justify-center gap-2 cursor-pointer" data-controller="heartbeat-export" data-action="click->heartbeat-export#openModal">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Export Date Range
</button>
</div>
<div class="mt-3 text-xs text-secondary">
<p><strong>All Heartbeats:</strong> Downloads your complete coding history, from the very start to your last heartbeat</p>
<p><strong>Date Range:</strong> Choose specific dates to export</p>
</div>
</div>
<% dev_tool do %>
<div class="p-6 bg-darkless border border-darkless rounded">
<div class="flex items-center gap-3 mb-3">
<div class="p-2 bg-green-600/10 rounded">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<h4 class="text-white font-medium">Import Heartbeat Data</h4>
</div>
<p class="text-gray-300 text-sm mb-4">Import ur data from real hackatime to test stuff with.</p>
<%= form_with url: import_my_heartbeats_path, method: :post, multipart: true, local: true, class: "space-y-4" do |form| %>
<div>
<%= form.file_field :heartbeat_file, accept: '.json,application/json', class: 'w-full px-3 py-2 bg-dark border border-darkless rounded text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-white hover:file:bg-primary/75 transition-colors cursor-pointer', required: true %>
</div>
<div class="flex gap-3">
<%= form.submit 'Import Heartbeats', class: 'w-full px-3 py-2 bg-primary hover:bg-primary/75 text-white text-md font-medium rounded transition-colors duration-200 cursor-pointer', data: { confirm: 'Are you sure you want to import heartbeats? This will add new data to your account.' } %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% admin_tool do %>
<div class="p-6 md:col-span-2">
<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">WakaTime Mirrors</h2>
</div>
<% if current_user.wakatime_mirrors.any? %>
<% grid_cols = current_user.wakatime_mirrors.size > 1 ? 'md:grid-cols-2' : '' %>
<div class="grid grid-cols-1 <%= grid_cols %> gap-4 mb-4">
<% current_user.wakatime_mirrors.each do |mirror| %>
<div class="p-4 bg-darkless border border-darkless rounded">
<h3 class="text-white font-medium"><%= mirror.endpoint_url %></h3>
<p class="text-secondary text-sm">Last synced: <%= mirror.last_synced_at ? time_ago_in_words(mirror.last_synced_at) + ' ago' : 'Never' %></p>
</div>
<% end %>
</div>
<% end %>
<%= form_with(model: [current_user, WakatimeMirror.new], local: true, class: "space-y-4") do |f| %>
<div class="grid grid-cols-1 gap-4">
<div>
<%= f.label :endpoint_url, class: 'block text-sm font-medium text-gray-200 mb-2' %>
<%= f.url_field :endpoint_url, value: 'https://wakatime.com/api/v1', class: 'w-full px-3 py-2 bg-darkless border border-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary' %>
</div>
<div>
<%= f.label :encrypted_api_key, 'WakaTime API Key', class: 'block text-sm font-medium text-gray-200 mb-2' %>
<%= f.password_field :encrypted_api_key, placeholder: 'Enter your WakaTime API key', class: 'w-full px-3 py-2 bg-darkless border border-darkless rounded text-white focus:border-primary focus:ring-1 focus:ring-primary' %>
</div>
</div>
<%= f.submit 'Add Mirror', class: 'px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200' %>
<% end %>
</div>
<% end %>
</div>
</div>
<div data-controller="api-key-rotation">
<%= render 'shared/modal', modal_id: 'api-key-confirm-modal', title: 'Rotate API Key?', description: "Your old key will be immediately invalidated and you'll need to update it in all your applications.", icon_svg: '<path fill="currentColor" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>', icon_color: 'text-primary', buttons: [{ text: 'Cancel', class: 'bg-dark hover:bg-darkless border border-darkless text-gray-300', action: 'click->modal#close' }, { text: 'Rotate Now', class: 'bg-primary hover:bg-primary/75 text-white font-medium', action: 'click->api-key-rotation#rotate' }] %>
<%= render 'shared/modal', modal_id: 'api-key-success-modal', title: 'New API Key Generated', description: 'Your old API key has been invalidated. Update your editor configuration with this new key:', icon_svg: '<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>', icon_color: 'text-green-500', max_width: 'max-w-lg', buttons: [{ text: 'Close', class: 'bg-dark hover:bg-darkless border border-darkless text-gray-300', action: 'click->modal#close' }, { text: 'Copy Key', class: 'bg-primary hover:bg-primary/75 text-white font-medium', action: 'click->api-key-rotation#copyKey' }], custom: '<div class="w-full mb-4"><div class="bg-darker rounded-lg p-3"><code id="new-api-key-display" class="text-sm text-white break-all" data-token=""></code></div></div>' %>
</div>
<% if @user.can_request_deletion? %>
<div data-controller="account-deletion">
<%= render 'shared/modal', modal_id: 'account-deletion-confirm-modal', title: 'Delete Your Account?', description: "This will permanently delete your account after a 30 day waiting period. During this time, you won't be able to use your account for any Hack Club programs.", icon_svg: '<path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>', icon_color: 'text-primary', buttons: [{ text: 'Cancel', class: 'bg-dark hover:bg-darkless border border-darkless text-gray-300', action: 'click->modal#close' }, { text: 'Delete My Account', class: 'bg-primary hover:bg-primary/75 text-white font-medium', form: true, url: create_deletion_path, method: 'post' }] %>
</div>
<% end %>
<%=
render 'shared/modal',
modal_id: 'export-date-range-modal',
title: 'Export Date Range',
description: 'Choose specific dates to export your coding activity.',
icon_svg: '<path fill="currentColor" d="M19 4h-1V3c0-.55-.45-1-1-1s-1 .45-1 1v1H8V3c0-.55-.45-1-1-1s-1 .45-1 1v1H5c-1.11 0-1.99.9-1.99 2L3 20a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 15c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1V9h14zM7 11h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/>',
icon_color: 'text-primary',
max_width: 'max-w-lg',
custom:
'
<form id="export-date-range-form" class="w-full space-y-4">
<div class="space-y-2">
<label for="export-start-date" class="block text-sm font-semibold text-white">Start Date</label>
<input type="date" id="export-start-date" name="start_date"
class="w-full px-4 py-3 bg-darkless text-white border border-darkless rounded-lg focus:border-primary focus:outline-none transition-colors">
</div>
<div class="space-y-2 mb-4">
<label for="export-end-date" class="block text-sm font-semibold text-white">End Date</label>
<input type="date" id="export-end-date" name="end_date"
class="w-full px-4 py-3 bg-darkless text-white border border-darkless rounded-lg focus:border-primary focus:outline-none transition-colors">
</div>
</form>',
buttons: [{ text: 'Cancel', class: 'bg-dark hover:bg-darkless border border-darkless text-gray-300', action: 'click->modal#close' }, { text: 'Export', class: 'bg-primary hover:bg-primary/75 text-white font-medium', form: true, form_id: 'export-date-range-form' }]
%>

View file

@ -5,22 +5,24 @@
"": {
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.13",
"@inertiajs/core": "file:vendor/inertia/packages/core",
"@inertiajs/svelte": "file:vendor/inertia/packages/svelte",
"@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",
"@tsconfig/svelte": "^5.0.7",
"axios": "^1.13.2",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "5",
"svelte-check": "^4.3.6",
"svelte": "^5.51.2",
"svelte-check": "^4.4.0",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-ruby": "^5.1.1",
"vite-plugin-ruby": "^5.1.2",
},
},
},
@ -91,9 +93,9 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@inertiajs/core": ["@inertiajs/core@2.3.15", "", { "dependencies": { "@types/lodash-es": "^4.17.12", "axios": "^1.13.5", "laravel-precognition": "^1.0.2", "lodash-es": "^4.17.23", "qs": "^6.14.2" } }, "sha512-C/x5w2/VhPpzfCg7SCtOxRrC8yKM7zIMvwpberMLrvSLMqPqGTFxYTJH+0E//G03RXzb+Q3+eatepbSq6tpZGw=="],
"@inertiajs/core": ["@inertiajs/core@file:vendor/inertia/packages/core", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "@types/lodash-es": "^4.17.12", "laravel-precognition": "2.0.0-beta.0", "lodash-es": "^4.17.23" }, "peerDependencies": { "axios": "^1.13.2" }, "optionalPeers": ["axios"] }],
"@inertiajs/svelte": ["@inertiajs/svelte@2.3.15", "", { "dependencies": { "@inertiajs/core": "2.3.15", "@types/lodash-es": "^4.17.12", "laravel-precognition": "^1.0.2", "lodash-es": "^4.17.23" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0" } }, "sha512-RFQYtVa1uZzzeh7c3nuHUQPj5fwCR138ShhXeoOYLj6tB4zXycIzuRmTKOSabwcgTGXHxfCkg77YfrCUb5Snig=="],
"@inertiajs/svelte": ["@inertiajs/svelte@file:vendor/inertia/packages/svelte", { "dependencies": { "@inertiajs/core": "file:../core", "@types/lodash-es": "^4.17.12", "laravel-precognition": "2.0.0-beta.0", "lodash-es": "^4.17.23" }, "peerDependencies": { "svelte": "^5.0.0" } }],
"@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=="],
@ -241,8 +243,6 @@
"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=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@ -395,7 +395,7 @@
"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=="],
"laravel-precognition": ["laravel-precognition@2.0.0-beta.0", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "axios": "^1.4.0" }, "optionalPeers": ["axios"] }, "sha512-em+Ke1x4ovACh2G0ed7DSxIegFy44OphZ8HpTDX6NH/L94N/Wuh4yIvoJeBMzdNz2HVurfSRJVHDkWlkIDwDYg=="],
"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=="],
@ -461,8 +461,6 @@
"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=="],
@ -493,8 +491,6 @@
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"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=="],
@ -517,21 +513,13 @@
"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.51.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "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-r8r6p+NFC2ckAkxW4lqpGs1AZWBi5Y+TbJMmAglqSbokN5UWkDsKKkybfGBKXd8yYMri7KJ2L78fO9SO+NOelA=="],
"svelte": ["svelte@5.51.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "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-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w=="],
"svelte-check": ["svelte-check@4.4.0", "", { "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-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="],
@ -567,6 +555,8 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@inertiajs/svelte/@inertiajs/core": ["@inertiajs/core@file:vendor/inertia/packages/core", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "@types/lodash-es": "^4.17.12", "laravel-precognition": "2.0.0-beta.0", "lodash-es": "^4.17.23" }, "peerDependencies": { "axios": "^1.13.2" }, "optionalPeers": ["axios"] }],
"@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=="],
@ -587,8 +577,6 @@
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"laravel-precognition/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=="],
"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=="],

View file

@ -2,7 +2,7 @@
InertiaRails.configure do |config|
config.version = ViteRuby.digest
config.encrypt_history = true
config.encrypt_history = Rails.env.production?
config.always_include_errors_hash = true
config.use_script_element_for_initial_page = true
config.use_data_inertia_head_attribute = true

View file

@ -121,8 +121,8 @@ Rails.application.routes.draw do
# Nested under users for admin access
resources :users, only: [] do
get "settings", on: :member, to: "users#edit"
patch "settings", on: :member, to: "users#update"
get "settings", on: :member, to: "settings/profile#show"
patch "settings", on: :member, to: "settings/profile#update"
member do
patch :update_trust_level
end
@ -133,10 +133,19 @@ Rails.application.routes.draw do
get "my/projects", to: "my/project_repo_mappings#index", as: :my_projects
# Namespace for current user actions
get "my/settings", to: "users#edit", as: :my_settings
patch "my/settings", to: "users#update"
post "my/settings/migrate_heartbeats", to: "users#migrate_heartbeats", as: :my_settings_migrate_heartbeats
post "my/settings/rotate_api_key", to: "users#rotate_api_key", as: :my_settings_rotate_api_key
get "my/settings", to: "settings/profile#show", as: :my_settings
patch "my/settings", to: "settings/profile#update"
get "my/settings/profile", to: "settings/profile#show", as: :my_settings_profile
patch "my/settings/profile", to: "settings/profile#update"
get "my/settings/integrations", to: "settings/integrations#show", as: :my_settings_integrations
patch "my/settings/integrations", to: "settings/integrations#update"
get "my/settings/access", to: "settings/access#show", as: :my_settings_access
patch "my/settings/access", to: "settings/access#update"
get "my/settings/badges", to: "settings/badges#show", as: :my_settings_badges
get "my/settings/data", to: "settings/data#show", as: :my_settings_data
get "my/settings/admin", to: "settings/admin#show", as: :my_settings_admin
post "my/settings/migrate_heartbeats", to: "settings/data#migrate_heartbeats", as: :my_settings_migrate_heartbeats
post "my/settings/rotate_api_key", to: "settings/access#rotate_api_key", as: :my_settings_rotate_api_key
namespace :my do
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do

438
package-lock.json generated
View file

@ -6,22 +6,24 @@
"": {
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.13",
"@inertiajs/core": "file:vendor/inertia/packages/core",
"@inertiajs/svelte": "file:vendor/inertia/packages/svelte",
"@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",
"@tsconfig/svelte": "^5.0.7",
"axios": "^1.13.2",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "5",
"svelte-check": "^4.3.6",
"svelte": "^5.51.2",
"svelte-check": "^4.4.0",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-ruby": "^5.1.1"
"vite-plugin-ruby": "^5.1.2"
}
},
"node_modules/@alloc/quick-lru": {
@ -55,9 +57,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
@ -71,9 +73,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
@ -87,9 +89,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
@ -103,9 +105,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
@ -119,7 +121,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
@ -133,9 +137,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
@ -149,9 +153,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
@ -165,9 +169,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
@ -181,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
@ -197,9 +201,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
@ -213,9 +217,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
@ -229,9 +233,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
@ -245,9 +249,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
@ -261,9 +265,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
@ -277,9 +281,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
@ -293,9 +297,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
@ -309,9 +313,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
@ -325,9 +329,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
@ -341,9 +345,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
@ -357,9 +361,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
@ -373,9 +377,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
@ -389,9 +393,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
@ -405,9 +409,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
@ -421,9 +425,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
@ -437,9 +441,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
@ -453,9 +457,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
@ -503,27 +507,36 @@
}
},
"node_modules/@inertiajs/core": {
"version": "2.3.13",
"version": "2.3.14",
"resolved": "file:vendor/inertia/packages/core",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"@types/lodash-es": "^4.17.12",
"axios": "^1.13.2",
"laravel-precognition": "^1.0.1",
"lodash-es": "^4.17.23",
"qs": "^6.14.1"
}
},
"node_modules/@inertiajs/svelte": {
"version": "2.3.13",
"license": "MIT",
"dependencies": {
"@inertiajs/core": "2.3.13",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "^1.0.1",
"laravel-precognition": "2.0.0-beta.0",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0"
"axios": "^1.13.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/@inertiajs/svelte": {
"version": "2.3.14",
"resolved": "file:vendor/inertia/packages/svelte",
"license": "MIT",
"dependencies": {
"@inertiajs/core": "file:../core",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "2.0.0-beta.0",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
@ -1421,6 +1434,12 @@
"@types/lodash": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.15.0",
"license": "MIT",
@ -1477,14 +1496,18 @@
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.4",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@ -1521,6 +1544,8 @@
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -1530,20 +1555,6 @@
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -1575,6 +1586,8 @@
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -1960,6 +1973,8 @@
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -1990,6 +2005,8 @@
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -2013,6 +2030,8 @@
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2020,6 +2039,8 @@
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2027,6 +2048,8 @@
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -2037,6 +2060,8 @@
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -2049,7 +2074,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.2",
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@ -2059,32 +2086,32 @@
"node": ">=18"
},
"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"
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/esm-env": {
@ -2164,6 +2191,8 @@
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@ -2182,6 +2211,8 @@
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -2214,6 +2245,8 @@
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -2236,6 +2269,8 @@
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -2259,6 +2294,8 @@
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2273,6 +2310,8 @@
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2283,6 +2322,8 @@
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -2419,11 +2460,20 @@
}
},
"node_modules/laravel-precognition": {
"version": "1.0.2",
"version": "2.0.0-beta.0",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0-beta.0.tgz",
"integrity": "sha512-em+Ke1x4ovACh2G0ed7DSxIegFy44OphZ8HpTDX6NH/L94N/Wuh4yIvoJeBMzdNz2HVurfSRJVHDkWlkIDwDYg==",
"license": "MIT",
"dependencies": {
"axios": "^1.4.0",
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"axios": "^1.4.0"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/layercake": {
@ -2760,6 +2810,8 @@
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2801,6 +2853,8 @@
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -2808,6 +2862,8 @@
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@ -2890,16 +2946,6 @@
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.1",
"funding": [
@ -3128,21 +3174,10 @@
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.1",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -3306,70 +3341,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.0",
"license": "MIT",
"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"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"license": "MIT",
"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"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"license": "BSD-3-Clause",
@ -3421,13 +3392,16 @@
}
},
"node_modules/svelte": {
"version": "5.49.2",
"version": "5.51.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.51.2.tgz",
"integrity": "sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
@ -3445,7 +3419,9 @@
}
},
"node_modules/svelte-check": {
"version": "4.3.6",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.0.tgz",
"integrity": "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg==",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",

View file

@ -6,16 +6,18 @@
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.15",
"@inertiajs/core": "file:vendor/inertia/packages/core",
"@inertiajs/svelte": "file:vendor/inertia/packages/svelte",
"@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.0.7",
"axios": "^1.13.2",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "^5.51.1",
"svelte": "^5.51.2",
"svelte-check": "^4.4.0",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",

4668
vendor/inertia/packages/core/dist/index.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,283 @@
// src/server.ts
import { originalPositionFor, TraceMap } from "@jridgewell/trace-mapping";
import { createServer } from "http";
import cluster from "node:cluster";
import { existsSync, readFileSync } from "node:fs";
import { availableParallelism } from "node:os";
import path from "node:path";
import * as process2 from "process";
// src/ssrErrors.ts
var sourceMapResolver = null;
function setSourceMapResolver(resolver) {
sourceMapResolver = resolver;
}
var BROWSER_APIS = {
// Global objects
window: "The global window object",
document: "The DOM document object",
navigator: "The navigator object",
location: "The location object",
history: "The browser history API",
screen: "The screen object",
localStorage: "Browser local storage",
sessionStorage: "Browser session storage",
// Viewport properties (accessed via window.X)
innerWidth: "Browser viewport width",
innerHeight: "Browser viewport height",
outerWidth: "Browser window width",
outerHeight: "Browser window height",
scrollX: "Horizontal scroll position",
scrollY: "Vertical scroll position",
devicePixelRatio: "The device pixel ratio",
matchMedia: "The matchMedia function",
// Observers (commonly instantiated at module level)
IntersectionObserver: "The IntersectionObserver API",
ResizeObserver: "The ResizeObserver API",
MutationObserver: "The MutationObserver API",
// Timing functions (commonly called at module level)
requestAnimationFrame: "The requestAnimationFrame function",
requestIdleCallback: "The requestIdleCallback function",
// Constructors that might be used at module level
Image: "The Image constructor",
Audio: "The Audio constructor",
Worker: "The Worker constructor",
BroadcastChannel: "The BroadcastChannel constructor",
// Network (older Node.js versions)
fetch: "The fetch API",
XMLHttpRequest: "The XMLHttpRequest API"
};
function detectBrowserApi(error) {
const message = error.message.toLowerCase();
for (const api of Object.keys(BROWSER_APIS)) {
const patterns = [
`${api.toLowerCase()} is not defined`,
`'${api.toLowerCase()}' is not defined`,
`"${api.toLowerCase()}" is not defined`,
`cannot read properties of undefined (reading '${api.toLowerCase()}')`
];
if (patterns.some((pattern) => message.includes(pattern))) {
return api;
}
}
return null;
}
function isComponentResolutionError(error) {
const message = error.message.toLowerCase();
return message.includes("cannot find module") || message.includes("failed to resolve") || message.includes("module not found") || message.includes("could not resolve");
}
function getBrowserApiHint(api) {
const apiDescription = BROWSER_APIS[api] || `The "${api}" object`;
return `${apiDescription} doesn't exist in Node.js. Wrap browser-specific code in a onMounted/useEffect/onMount lifecycle hook, or check "typeof ${api} !== 'undefined'" before using it.`;
}
function extractSourceLocation(stack) {
if (!stack) {
return void 0;
}
for (const line of stack.split("\n")) {
if (!line.includes("at ")) {
continue;
}
if (line.includes("node_modules") || line.includes("node:")) {
continue;
}
let match = line.match(/\(([^)]+):(\d+):(\d+)\)/);
if (!match) {
match = line.match(/at\s+(?:file:\/\/)?(.+):(\d+):(\d+)\s*$/);
}
if (match) {
const file = match[1].replace(/^file:\/\//, "");
const lineNum = parseInt(match[2], 10);
const colNum = parseInt(match[3], 10);
if (sourceMapResolver) {
const resolved = sourceMapResolver(file, lineNum, colNum);
if (resolved) {
return `${resolved.file}:${resolved.line}:${resolved.column}`;
}
}
return `${file}:${lineNum}:${colNum}`;
}
}
return void 0;
}
function classifySSRError(error, component, url) {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
const base = {
error: error.message,
component,
url,
stack: error.stack,
sourceLocation: extractSourceLocation(error.stack),
timestamp
};
const browserApi = detectBrowserApi(error);
if (browserApi) {
return {
...base,
type: "browser-api",
browserApi,
hint: getBrowserApiHint(browserApi)
};
}
if (isComponentResolutionError(error)) {
return {
...base,
type: "component-resolution",
hint: `Could not resolve component${component ? ` "${component}"` : ""}. Check that the file exists and the path is correct.`
};
}
return {
...base,
type: "render",
hint: "An error occurred while rendering. Check for browser-specific code that runs during initialization."
};
}
var colors = {
reset: "\x1B[0m",
red: "\x1B[31m",
yellow: "\x1B[33m",
cyan: "\x1B[36m",
dim: "\x1B[2m",
bold: "\x1B[1m",
bgRed: "\x1B[41m",
white: "\x1B[37m"
};
function makeRelative(path2) {
const cwd = process.cwd();
if (path2.startsWith(cwd + "/")) {
return path2.slice(cwd.length + 1);
}
return path2;
}
function formatConsoleError(classified) {
const componentPart = classified.component ? ` ${colors.cyan}${classified.component}${colors.reset}` : "";
const lines = [
"",
` ${colors.bgRed}${colors.white}${colors.bold} SSR ERROR ${colors.reset}${componentPart}`,
"",
` ${classified.error}`
];
if (classified.sourceLocation) {
const relativePath = makeRelative(classified.sourceLocation);
lines.push(` ${colors.dim}Source: ${relativePath}${colors.reset}`);
}
if (classified.url) {
lines.push(` ${colors.dim}URL: ${classified.url}${colors.reset}`);
}
lines.push("", ` ${colors.yellow}Hint${colors.reset} ${classified.hint}`, "");
return lines.join("\n");
}
// src/server.ts
var sourceMaps = /* @__PURE__ */ new Map();
setSourceMapResolver((file, line, column) => {
if (!file.includes("/ssr/") || !file.endsWith(".js")) {
return null;
}
const mapFile = file + ".map";
if (!existsSync(mapFile)) {
return null;
}
let traceMap = sourceMaps.get(mapFile);
if (!traceMap) {
try {
const mapContent = readFileSync(mapFile, "utf-8");
traceMap = new TraceMap(mapContent);
sourceMaps.set(mapFile, traceMap);
} catch {
return null;
}
}
const original = originalPositionFor(traceMap, { line, column });
if (original.source) {
const mapDir = path.dirname(mapFile);
const resolvedPath = path.resolve(mapDir, original.source);
return {
file: resolvedPath,
line: original.line ?? line,
column: original.column ?? column
};
}
return null;
});
var readableToString = (readable) => new Promise((resolve, reject) => {
let data = "";
readable.on("data", (chunk) => data += chunk);
readable.on("end", () => resolve(data));
readable.on("error", (err) => reject(err));
});
var server_default = (render, options) => {
const opts = typeof options === "number" ? { port: options } : options;
const { port = 13714, cluster: useCluster = false, handleErrors = true } = opts ?? {};
const log = (message) => {
console.log(
useCluster && !cluster.isPrimary ? `[${cluster.worker?.id ?? "N/A"} / ${cluster.worker?.process?.pid ?? "N/A"}] ${message}` : message
);
};
if (useCluster && cluster.isPrimary) {
log("Primary Inertia SSR server process started...");
for (let i = 0; i < availableParallelism(); i++) {
cluster.fork();
}
cluster.on("message", (_worker, message) => {
if (message === "shutdown") {
for (const id in cluster.workers) {
cluster.workers[id]?.kill();
}
process2.exit();
}
});
return render;
}
const handleRender = async (request, response) => {
const page = JSON.parse(await readableToString(request));
const originalWarn = console.warn;
if (handleErrors) {
console.warn = () => {
};
}
try {
const result = await render(page);
response.writeHead(200, { "Content-Type": "application/json", Server: "Inertia.js SSR" });
response.write(JSON.stringify(result));
} catch (e) {
const error = e;
if (!handleErrors) {
throw error;
}
const classified = classifySSRError(error, page.component, page.url);
console.error(formatConsoleError(classified));
response.writeHead(500, { "Content-Type": "application/json", Server: "Inertia.js SSR" });
response.write(JSON.stringify(classified));
} finally {
console.warn = originalWarn;
}
};
const routes = {
"/health": async () => ({ status: "OK", timestamp: Date.now() }),
"/shutdown": async () => {
if (cluster.isWorker) {
process2.send?.("shutdown");
}
process2.exit();
},
"/render": handleRender,
"/404": async () => ({ status: "NOT_FOUND", timestamp: Date.now() })
};
createServer(async (request, response) => {
const dispatchRoute = routes[request.url] ?? routes["/404"];
const result = await dispatchRoute(request, response);
if (!response.headersSent) {
response.writeHead(200, { "Content-Type": "application/json", Server: "Inertia.js SSR" });
response.write(JSON.stringify(result));
}
response.end();
}).listen(port, () => log("Inertia SSR server started."));
log(`Starting SSR server on port ${port}...`);
return render;
};
export {
BROWSER_APIS,
server_default as default
};
//# sourceMappingURL=server.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,59 @@
{
"name": "@inertiajs/core",
"version": "2.3.14",
"license": "MIT",
"description": "A framework for creating server-driven single page apps.",
"contributors": [
"Jonathan Reinink <jonathan@reinink.ca>",
"Claudio Dekker <claudio@ubient.net>",
"Sebastian De Deyne <sebastiandedeyne@gmail.com>"
],
"homepage": "https://inertiajs.com/",
"repository": {
"type": "git",
"url": "git+https://github.com/inertiajs/inertia.git",
"directory": "packages/inertia"
},
"bugs": {
"url": "https://github.com/inertiajs/inertia/issues"
},
"files": [
"dist",
"types"
],
"type": "module",
"main": "dist/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./types/server.d.ts",
"import": "./dist/server.js"
}
},
"typesVersions": {
"*": {
"server": [
"types/server.d.ts"
]
}
},
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "2.0.0-beta.0",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"axios": "^1.13.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
},
"x-inertia-source": "https://github.com/inertiajs/inertia/tree/504fa085db13066c48d447a00b1c9f80d91ef7b6"
}

View file

@ -0,0 +1,16 @@
import type { AxiosInstance } from 'axios';
import { HttpClient, HttpRequestConfig, HttpResponse } from './types';
/**
* HTTP client implementation using Axios
*/
export declare class AxiosHttpClient implements HttpClient {
private axios?;
constructor(instance?: AxiosInstance);
private getAxios;
request(config: HttpRequestConfig): Promise<HttpResponse>;
protected doRequest(config: HttpRequestConfig): Promise<HttpResponse>;
}
/**
* Create an Axios HTTP client adapter
*/
export declare function axiosAdapter(instance?: AxiosInstance): HttpClient;

View file

@ -0,0 +1,24 @@
import { InertiaAppConfig } from './types';
type ConfigKeys<T> = T extends Function ? never : string extends keyof T ? string : Extract<keyof T, string> | {
[Key in Extract<keyof T, string>]: T[Key] extends object ? `${Key}.${ConfigKeys<T[Key]> & string}` : never;
}[Extract<keyof T, string>];
type ConfigValue<T, K extends ConfigKeys<T>> = K extends `${infer P}.${infer Rest}` ? P extends keyof T ? Rest extends ConfigKeys<T[P]> ? ConfigValue<T[P], Rest> : never : never : K extends keyof T ? T[K] : never;
type ConfigSetObject<T> = {
[K in ConfigKeys<T>]?: ConfigValue<T, K>;
};
type FirstLevelOptional<T> = {
[K in keyof T]?: T[K] extends object ? {
[P in keyof T[K]]?: T[K][P];
} : T[K];
};
export declare class Config<TConfig extends {} = {}> {
protected config: FirstLevelOptional<TConfig>;
protected defaults: TConfig;
constructor(defaults: TConfig);
extend<TExtension extends {}>(defaults?: TExtension): Config<TConfig & TExtension>;
replace(newConfig: FirstLevelOptional<TConfig>): void;
get<K extends ConfigKeys<TConfig>>(key: K): ConfigValue<TConfig, K>;
set<K extends ConfigKeys<TConfig>>(keyOrValues: K | Partial<ConfigSetObject<TConfig>>, value?: ConfigValue<TConfig, K>): void;
}
export declare const config: Config<InertiaAppConfig>;
export {};

View file

@ -0,0 +1 @@
export default function debounce<F extends (...params: any[]) => ReturnType<F>>(fn: F, delay: number): F;

View file

@ -0,0 +1 @@
export declare const stackTrace: (autolog?: boolean) => string | undefined;

View file

@ -0,0 +1,8 @@
declare const _default: {
createIframeAndPage(html: Record<string, unknown> | string): {
iframe: HTMLIFrameElement;
page: HTMLElement;
};
show(html: Record<string, unknown> | string): void;
};
export default _default;

View file

@ -0,0 +1,4 @@
export declare const getScrollableParent: (element: HTMLElement | null) => HTMLElement | null;
export declare const getElementsInViewportFromCollection: (elements: HTMLElement[], referenceElement?: HTMLElement) => HTMLElement[];
export declare const requestAnimationFrame: (cb: () => void, times?: number) => void;
export declare const getInitialPageFromDOM: <T>(id: string) => T | null;

View file

@ -0,0 +1,6 @@
export declare const encryptHistory: (data: any) => Promise<ArrayBuffer>;
export declare const historySessionStorageKeys: {
key: string;
iv: string;
};
export declare const decryptHistory: (data: any) => Promise<any>;

View file

@ -0,0 +1,17 @@
import { GlobalEvent, GlobalEventNames, GlobalEventResult, InternalEvent } from './types';
declare class EventHandler {
protected internalListeners: {
event: InternalEvent;
listener: (...args: any[]) => void;
}[];
init(): void;
onGlobalEvent<TEventName extends GlobalEventNames>(type: TEventName, callback: (event: GlobalEvent<TEventName>) => GlobalEventResult<TEventName>): VoidFunction;
on(event: InternalEvent, callback: (...args: any[]) => void): VoidFunction;
onMissingHistoryItem(): void;
fireInternalEvent(event: InternalEvent, ...args: any[]): void;
protected registerListener(type: string, listener: EventListener): VoidFunction;
protected handlePageshowEvent(event: PageTransitionEvent): void;
protected handlePopstateEvent(event: PopStateEvent): void;
}
export declare const eventHandler: EventHandler;
export {};

View file

@ -0,0 +1,14 @@
import { GlobalEventTrigger } from './types';
export declare const fireBeforeEvent: GlobalEventTrigger<'before'>;
export declare const fireErrorEvent: GlobalEventTrigger<'error'>;
export declare const fireNetworkErrorEvent: GlobalEventTrigger<'networkError'>;
export declare const fireFinishEvent: GlobalEventTrigger<'finish'>;
export declare const fireHttpExceptionEvent: GlobalEventTrigger<'httpException'>;
export declare const fireBeforeUpdateEvent: GlobalEventTrigger<'beforeUpdate'>;
export declare const fireNavigateEvent: GlobalEventTrigger<'navigate'>;
export declare const fireProgressEvent: GlobalEventTrigger<'progress'>;
export declare const fireStartEvent: GlobalEventTrigger<'start'>;
export declare const fireSuccessEvent: GlobalEventTrigger<'success'>;
export declare const firePrefetchedEvent: GlobalEventTrigger<'prefetched'>;
export declare const firePrefetchingEvent: GlobalEventTrigger<'prefetching'>;
export declare const fireFlashEvent: GlobalEventTrigger<'flash'>;

View file

@ -0,0 +1,3 @@
import { FormDataConvertible, RequestPayload } from './types';
export declare const isFile: (value: unknown) => boolean;
export declare function hasFiles(data: RequestPayload | FormDataConvertible): boolean;

View file

@ -0,0 +1,3 @@
import type { FormDataConvertible, QueryStringArrayFormatOption } from './types';
export declare const isFormData: (value: any) => value is FormData;
export declare function objectToFormData(source: Record<string, FormDataConvertible>, form?: FormData, parentKey?: string | null, queryStringArrayFormat?: QueryStringArrayFormatOption): FormData;

View file

@ -0,0 +1,5 @@
import { FormDataConvertible } from './types';
/**
* Convert a FormData instance into an object structure.
*/
export declare function formDataToObject(source: FormData): Record<string, FormDataConvertible>;

View file

@ -0,0 +1,2 @@
import { HeadManager, HeadManagerOnUpdateCallback, HeadManagerTitleCallback } from '.';
export default function createHeadManager(isServer: boolean, titleCallback: HeadManagerTitleCallback, onUpdate: HeadManagerOnUpdateCallback): HeadManager;

View file

@ -0,0 +1,48 @@
import { Page, ScrollRegion } from './types';
declare class History {
rememberedState: "rememberedState";
scrollRegions: "scrollRegions";
preserveUrl: boolean;
protected current: Partial<Page>;
protected initialState: Partial<Page> | null;
remember(data: unknown, key: string): void;
restore(key: string): unknown;
pushState(page: Page, cb?: (() => void) | null): void;
protected clonePageProps(page: Page): Page;
protected getPageData(page: Page): Promise<Page | ArrayBuffer>;
processQueue(): Promise<void>;
decrypt(page?: Page | null): Promise<Page>;
protected decryptPageData(pageData: ArrayBuffer | Page | null): Promise<Page | null>;
saveScrollPositions(scrollRegions: ScrollRegion[]): void;
saveDocumentScrollPosition(scrollRegion: ScrollRegion): void;
getScrollRegions(): ScrollRegion[];
getDocumentScrollPosition(): ScrollRegion;
replaceState(page: Page, cb?: (() => void) | null): void;
protected isHistoryThrottleError(error: unknown): error is Error & {
name: 'SecurityError';
};
protected isQuotaExceededError(error: unknown): error is Error & {
name: 'QuotaExceededError';
};
protected withThrottleProtection<T = void>(cb: () => T): Promise<T | undefined>;
protected doReplaceState(data: {
page: Page | ArrayBuffer;
scrollRegions?: ScrollRegion[];
documentScrollPosition?: ScrollRegion;
}, url?: string): Promise<void>;
protected doPushState(data: {
page: Page | ArrayBuffer;
scrollRegions?: ScrollRegion[];
documentScrollPosition?: ScrollRegion;
}, url: string): Promise<void>;
getState<T>(key: keyof Page, defaultValue?: T): any;
deleteState(key: keyof Page): void;
clearInitialState(key: keyof Page): void;
browserHasHistoryEntry(): boolean;
clear(): void;
setCurrent(page: Page): void;
isValidState(state: any): boolean;
getAllState(): Page;
}
export declare const history: History;
export {};

View file

@ -0,0 +1,38 @@
import { HttpClient, HttpClientOptions } from './types';
export declare const http: {
/**
* Get the current HTTP client
*/
getClient(): HttpClient;
/**
* Set the HTTP client to use for all Inertia requests
*/
setClient(clientOrOptions: HttpClient | HttpClientOptions): void;
/**
* Register a request handler that runs before each request
*/
onRequest: (handler: import("./types").HttpRequestHandler) => () => void;
/**
* Register a response handler that runs after each successful response
*/
onResponse: (handler: import("./types").HttpResponseHandler) => () => void;
/**
* Register an error handler that runs when a request fails
*/
onError: (handler: import("./types").HttpErrorHandler) => () => void;
/**
* Process a request config through all registered request handlers.
* For use by custom HttpClient implementations.
*/
processRequest: (config: import("./types").HttpRequestConfig) => Promise<import("./types").HttpRequestConfig>;
/**
* Process a response through all registered response handlers.
* For use by custom HttpClient implementations.
*/
processResponse: (response: import("./types").HttpResponse) => Promise<import("./types").HttpResponse>;
/**
* Process an error through all registered error handlers.
* For use by custom HttpClient implementations.
*/
processError: (error: import("./httpErrors").HttpResponseError | import("./httpErrors").HttpNetworkError | import("./httpErrors").HttpCancelledError) => Promise<void>;
};

View file

@ -0,0 +1,16 @@
import { HttpResponse } from './types';
export declare class HttpResponseError extends Error {
readonly response: HttpResponse;
readonly url?: string;
constructor(message: string, response: HttpResponse, url?: string);
}
export declare class HttpCancelledError extends Error {
readonly url?: string;
constructor(message?: string, url?: string);
}
export declare class HttpNetworkError extends Error {
readonly cause?: Error;
readonly code = "ERR_NETWORK";
readonly url?: string;
constructor(message: string, url?: string, cause?: Error);
}

View file

@ -0,0 +1,15 @@
import { HttpCancelledError, HttpNetworkError, HttpResponseError } from './httpErrors';
import { HttpErrorHandler, HttpRequestConfig, HttpRequestHandler, HttpResponse, HttpResponseHandler } from './types';
declare class HttpHandlers {
protected requestHandlers: HttpRequestHandler[];
protected responseHandlers: HttpResponseHandler[];
protected errorHandlers: HttpErrorHandler[];
onRequest(handler: HttpRequestHandler): () => void;
onResponse(handler: HttpResponseHandler): () => void;
onError(handler: HttpErrorHandler): () => void;
processRequest(config: HttpRequestConfig): Promise<HttpRequestConfig>;
processResponse(response: HttpResponse): Promise<HttpResponse>;
processError(error: HttpResponseError | HttpNetworkError | HttpCancelledError): Promise<void>;
}
export declare const httpHandlers: HttpHandlers;
export {};

View file

@ -0,0 +1,23 @@
import { Config } from './config';
import { Router } from './router';
export { UseFormUtils } from './useFormUtils';
export { axiosAdapter } from './axiosHttpClient';
export { config } from './config';
export { getInitialPageFromDOM, getScrollableParent } from './domUtils';
export { hasFiles } from './files';
export { objectToFormData } from './formData';
export { formDataToObject } from './formObject';
export { default as createHeadManager } from './head';
export { http } from './http';
export { HttpCancelledError, HttpNetworkError, HttpResponseError } from './httpErrors';
export { default as useInfiniteScroll } from './infiniteScroll';
export { createLayoutPropsStore, mergeLayoutProps, normalizeLayouts, type LayoutDefinition, type LayoutPropsStore, } from './layout';
export { shouldIntercept, shouldNavigate } from './navigationEvents';
export { progress, default as setupProgress } from './progress';
export { FormComponentResetSymbol, resetFormFields } from './resetFormFields';
export { buildSSRBody } from './ssrUtils';
export * from './types';
export { hrefToUrl, isSameUrlWithoutQueryOrHash, isUrlMethodPair, mergeDataIntoQueryString, urlHasProtocol, urlToString, urlWithoutHash, } from './url';
export { XhrHttpClient, xhrHttpClient } from './xhrHttpClient';
export { type Config, type Router };
export declare const router: Router;

View file

@ -0,0 +1,12 @@
import { UseInfiniteScrollOptions, UseInfiniteScrollProps } from './types';
/**
* Core infinite scroll composable that orchestrates data fetching, DOM management,
* scroll preservation, and URL synchronization.
*
* This is the main entry point that coordinates four sub-systems:
* - Data management: Handles pagination state and server requests
* - Element management: DOM observation and intersection detection
* - Query string sync: Updates URL as user scrolls through pages
* - Scroll preservation: Maintains scroll position during content updates
*/
export default function useInfiniteScroll(options: UseInfiniteScrollOptions): UseInfiniteScrollProps;

View file

@ -0,0 +1,10 @@
import { UseInfiniteScrollDataManager } from '../types';
export declare const useInfiniteScrollData: (options: {
getPropName: () => string;
onBeforeUpdate: () => void;
onBeforePreviousRequest: () => void;
onBeforeNextRequest: () => void;
onCompletePreviousRequest: (loadedPage: string | number | null) => void;
onCompleteNextRequest: (loadedPage: string | number | null) => void;
onReset?: () => void;
}) => UseInfiniteScrollDataManager;

View file

@ -0,0 +1,15 @@
import { UseInfiniteScrollElementManager } from '../types';
export declare const getPageFromElement: (element: HTMLElement) => string | undefined;
export declare const useInfiniteScrollElementManager: (options: {
shouldFetchNext: () => boolean;
shouldFetchPrevious: () => boolean;
getTriggerMargin: () => number;
getStartElement: () => HTMLElement;
getEndElement: () => HTMLElement;
getItemsElement: () => HTMLElement;
getScrollableParent: () => HTMLElement | null;
onPreviousTriggered: () => void;
onNextTriggered: () => void;
onItemIntersected: (element: HTMLElement) => void;
getPropName: () => string;
}) => UseInfiniteScrollElementManager;

View file

@ -0,0 +1,13 @@
/**
* As users scroll through infinite content, this system updates the URL to reflect
* which page they're currently viewing. It uses a "most visible page" calculation
* so that the URL reflects whichever page has the most visible items.
*/
export declare const useInfiniteScrollQueryString: (options: {
getPageName: () => string;
getItemsElement: () => HTMLElement;
shouldPreserveUrl: () => boolean;
}) => {
onItemIntersected: (itemElement: HTMLElement) => void;
cancel: () => boolean;
};

View file

@ -0,0 +1,18 @@
/**
* When loading content "before" the current viewport (e.g. loading page 1 when viewing page 2),
* new content is prepended to the DOM, which naturally pushes existing content down and
* disrupts the user's scroll position. This system maintains visual stability by:
*
* 1. Capturing a reference element and its position before the update
* 2. After new content is added, calculating how far that reference element moved
* 3. Adjusting scroll position to keep the reference element in the same visual location
*/
export declare const useInfiniteScrollPreservation: (options: {
getScrollableParent: () => HTMLElement | null;
getItemsElement: () => HTMLElement;
}) => {
createCallbacks: () => {
captureScrollPosition: () => void;
restoreScrollPosition: () => void;
};
};

View file

@ -0,0 +1,10 @@
export declare class InitialVisit {
static handle(): void;
protected static clearRememberedStateOnReload(): void;
protected static handleBackForward(): boolean;
/**
* @link https://inertiajs.com/redirects#external-redirects
*/
protected static handleLocation(): boolean;
protected static handleDefault(): void;
}

View file

@ -0,0 +1,7 @@
type IntersectionObserverCallback = (entry: IntersectionObserverEntry) => void;
interface IntersectionObserverManager {
new: (callback: IntersectionObserverCallback, options?: IntersectionObserverInit) => IntersectionObserver;
flushAll: () => void;
}
export declare const useIntersectionObservers: () => IntersectionObserverManager;
export {};

View file

@ -0,0 +1,38 @@
export interface LayoutDefinition<Component> {
component: Component;
props: Record<string, unknown>;
name?: string;
}
export interface LayoutPropsStore {
set(props: Record<string, unknown>): void;
setFor(name: string, props: Record<string, unknown>): void;
get(): {
shared: Record<string, unknown>;
named: Record<string, Record<string, unknown>>;
};
reset(): void;
subscribe(callback: () => void): () => void;
}
export declare function createLayoutPropsStore(): LayoutPropsStore;
/**
* Merges layout props from three sources with priority: dynamic > static > defaults.
* Only keys present in `defaults` are included in the result.
*
* @example
* ```ts
* mergeLayoutProps(
* { title: 'Default', showSidebar: true }, // defaults declared in useLayoutProps()
* { title: 'My Page', color: 'blue' }, // static props from layout definition
* { showSidebar: false, fontSize: 16 }, // dynamic props from setLayoutProps()
* )
* // => { title: 'My Page', showSidebar: false }
* // 'color' and 'fontSize' are excluded because they're not declared in defaults
* ```
*/
export declare function mergeLayoutProps<T extends Record<string, unknown>>(defaults: T, staticProps: Record<string, unknown>, dynamicProps: Record<string, unknown>): T;
type ComponentCheck<T> = (value: unknown) => value is T;
/**
* Normalizes layout definitions into a consistent structure.
*/
export declare function normalizeLayouts<T>(layout: unknown, isComponent: ComponentCheck<T>, isRenderFunction?: (value: unknown) => boolean): LayoutDefinition<T>[];
export {};

View file

@ -0,0 +1,15 @@
type MouseNavigationEvent = Pick<MouseEvent, 'altKey' | 'ctrlKey' | 'shiftKey' | 'metaKey' | 'button' | 'currentTarget' | 'defaultPrevented' | 'target'>;
type KeyboardNavigationEvent = Pick<KeyboardEvent, 'currentTarget' | 'defaultPrevented' | 'key' | 'target'>;
/**
* Determine if this mouse event should be intercepted for navigation purposes.
* Links with modifier keys or non-left clicks should not be intercepted.
* Content editable elements and prevented events are ignored.
*/
export declare function shouldIntercept(event: MouseNavigationEvent): boolean;
/**
* Determine if this keyboard event should trigger a navigation request.
* Enter triggers navigation for both links and buttons currently.
* Space only triggers navigation for buttons specifically.
*/
export declare function shouldNavigate(event: KeyboardNavigationEvent): boolean;
export {};

View file

@ -0,0 +1,10 @@
declare class NavigationType {
protected type: NavigationTimingType;
constructor();
protected resolveType(): NavigationTimingType;
get(): NavigationTimingType;
isBackForward(): boolean;
isReload(): boolean;
}
export declare const navigationType: NavigationType;
export {};

View file

@ -0,0 +1 @@
export declare const objectsAreEqual: <T extends Record<string, any>>(obj1: T, obj2: T, excludeKeys: { [K in keyof T]: K; }[keyof T][]) => boolean;

View file

@ -0,0 +1,51 @@
import { Component, FlashData, Page, PageEvent, PageHandler, PageResolver, RouterInitParams, Visit } from './types';
declare class CurrentPage {
protected page: Page;
protected swapComponent: PageHandler<any>;
protected resolveComponent: PageResolver;
protected onFlashCallback?: (flash: Page['flash']) => void;
protected componentId: {};
protected listeners: {
event: PageEvent;
callback: VoidFunction;
}[];
protected isFirstPageLoad: boolean;
protected cleared: boolean;
protected pendingDeferredProps: Pick<Page, 'deferredProps' | 'url' | 'component'> | null;
protected historyQuotaExceeded: boolean;
init<ComponentType = Component>({ initialPage, swapComponent, resolveComponent, onFlash, }: RouterInitParams<ComponentType>): this;
set(page: Page, { replace, preserveScroll, preserveState, viewTransition, }?: {
replace?: boolean;
preserveScroll?: boolean;
preserveState?: boolean;
viewTransition?: Visit['viewTransition'];
}): Promise<void>;
setQuietly(page: Page, { preserveState, }?: {
preserveState?: boolean;
}): Promise<unknown>;
clear(): void;
isCleared(): boolean;
get(): Page;
getWithoutFlashData(): Page;
hasOnceProps(): boolean;
merge(data: Partial<Page>): void;
setPropsQuietly(props: Page['props']): Promise<unknown>;
setFlash(flash: FlashData): void;
setUrlHash(hash: string): void;
remember(data: Page['rememberedState']): void;
swap({ component, page, preserveState, viewTransition, }: {
component: Component;
page: Page;
preserveState: boolean;
viewTransition: Visit['viewTransition'];
}): Promise<unknown>;
resolve(component: string, page?: Page): Promise<Component>;
isTheSame(page: Page): boolean;
on(event: PageEvent, callback: VoidFunction): VoidFunction;
fireEventsFor(event: PageEvent): void;
mergeOncePropsIntoResponse(response: Page, { force }?: {
force?: boolean;
}): void;
}
export declare const page: CurrentPage;
export {};

View file

@ -0,0 +1,13 @@
import { PollOptions } from './types';
export declare class Poll {
protected id: number | null;
protected throttle: boolean;
protected keepAlive: boolean;
protected cb: VoidFunction;
protected interval: number;
protected cbCount: number;
constructor(interval: number, cb: VoidFunction, options: PollOptions);
stop(): void;
start(): void;
isInBackground(hidden: boolean): void;
}

View file

@ -0,0 +1,14 @@
import { Poll } from './poll';
import { PollOptions } from './types';
declare class Polls {
protected polls: Poll[];
constructor();
add(interval: number, cb: VoidFunction, options: PollOptions): {
stop: VoidFunction;
start: VoidFunction;
};
clear(): void;
protected setupVisibilityListener(): void;
}
export declare const polls: Polls;
export {};

View file

@ -0,0 +1,28 @@
import { Response } from './response';
import { ActiveVisit, CacheForOption, InFlightPrefetch, InternalActiveVisit, Page, PrefetchedResponse, PrefetchOptions, PrefetchRemovalTimer } from './types';
declare class PrefetchedRequests {
protected cached: PrefetchedResponse[];
protected inFlightRequests: InFlightPrefetch[];
protected removalTimers: PrefetchRemovalTimer[];
protected currentUseId: string | null;
add(params: ActiveVisit, sendFunc: (params: InternalActiveVisit) => void, { cacheFor, cacheTags }: PrefetchOptions): Promise<void> | Promise<Response>;
removeAll(): void;
removeByTags(tags: string[]): void;
remove(params: ActiveVisit): void;
protected removeFromInFlight(params: ActiveVisit): void;
protected extractStaleValues(cacheFor: PrefetchOptions['cacheFor']): [number, number];
protected cacheForToStaleAndExpires(cacheFor: PrefetchOptions['cacheFor']): [CacheForOption, CacheForOption];
protected clearTimer(params: ActiveVisit): void;
protected scheduleForRemoval(params: ActiveVisit, expiresIn: number): void;
get(params: ActiveVisit): InFlightPrefetch | PrefetchedResponse | null;
use(prefetched: PrefetchedResponse | InFlightPrefetch, params: ActiveVisit): Promise<void | undefined>;
protected removeSingleUseItems(params: ActiveVisit): void;
findCached(params: ActiveVisit): PrefetchedResponse | null;
findInFlight(params: ActiveVisit): InFlightPrefetch | null;
protected withoutPurposePrefetchHeader(params: ActiveVisit): ActiveVisit;
protected paramsAreEqual(params1: ActiveVisit, params2: ActiveVisit): boolean;
updateCachedOncePropsFromCurrentPage(): void;
protected getShortestOncePropTtl(page: Page): number | null;
}
export declare const prefetchedRequests: PrefetchedRequests;
export {};

View file

@ -0,0 +1,13 @@
import { ProgressSettings } from './types';
declare const _default: {
configure: (options: Partial<ProgressSettings>) => void;
isStarted: () => boolean;
done: (force?: boolean) => void;
set: (n: number) => void;
remove: () => void;
start: () => void;
status: null;
show: () => void;
hide: () => void;
};
export default _default;

View file

@ -0,0 +1,20 @@
declare class Progress {
hideCount: number;
start(): void;
reveal(force?: boolean): void;
hide(): void;
set(status: number): void;
finish(): void;
reset(): void;
remove(): void;
isStarted(): boolean;
getStatus(): number | null;
}
export declare const progress: Progress;
export default function setupProgress({ delay, color, includeCSS, showSpinner, }?: {
delay?: number | undefined;
color?: string | undefined;
includeCSS?: boolean | undefined;
showSpinner?: boolean | undefined;
}): void;
export {};

View file

@ -0,0 +1,13 @@
import type { QueryStringArrayFormatOption } from './types';
/**
* Returns true if the given URL query string contains indexed array parameters.
*/
export declare function hasIndices(url: URL): boolean;
/**
* Parse a query string into a nested object.
*/
export declare function parse(query: string): Record<string, unknown>;
/**
* Convert an object to a query string.
*/
export declare function stringify(data: Record<string, unknown>, arrayFormat: QueryStringArrayFormatOption): string;

View file

@ -0,0 +1,7 @@
export default class Queue<T> {
protected items: (() => T)[];
protected processingPromise: Promise<void> | null;
add(item: () => T): Promise<void>;
process(): Promise<void>;
protected processNext(): Promise<void>;
}

View file

@ -0,0 +1,23 @@
import { RequestParams } from './requestParams';
import { Response } from './response';
import type { ActiveVisit, Page } from './types';
import { HttpProgressEvent, HttpRequestHeaders } from './types';
export declare class Request {
protected page: Page;
protected response: Response;
protected cancelToken: AbortController;
protected requestParams: RequestParams;
protected requestHasFinished: boolean;
constructor(params: ActiveVisit, page: Page);
static create(params: ActiveVisit, page: Page): Request;
isPrefetch(): boolean;
send(): Promise<void | undefined>;
protected finish(): void;
protected fireFinishEvents(): void;
cancel({ cancelled, interrupted }: {
cancelled?: boolean;
interrupted?: boolean;
}): void;
protected onProgress(progress: HttpProgressEvent): void;
protected getHeaders(): HttpRequestHeaders;
}

View file

@ -0,0 +1,36 @@
import { Response } from './response';
import { ActiveVisit, HttpRequestHeaders, InternalActiveVisit, Page, PreserveStateOption, VisitCallbacks } from './types';
export declare class RequestParams {
protected callbacks: {
name: keyof VisitCallbacks;
args: any[];
}[];
protected params: InternalActiveVisit;
constructor(params: InternalActiveVisit);
static create(params: ActiveVisit): RequestParams;
data(): import("./types").RequestPayload | null;
queryParams(): import("./types").RequestPayload;
isPartial(): boolean;
isPrefetch(): boolean;
isDeferredPropsRequest(): boolean;
onCancelToken(cb: VoidFunction): void;
markAsFinished(): void;
markAsCancelled({ cancelled, interrupted }: {
cancelled?: boolean | undefined;
interrupted?: boolean | undefined;
}): void;
wasCancelledAtAll(): boolean;
onFinish(): void;
onStart(): void;
onPrefetching(): void;
onPrefetchResponse(response: Response): void;
onPrefetchError(error: Error): void;
all(): InternalActiveVisit;
headers(): HttpRequestHeaders;
setPreserveOptions(page: Page): void;
runCallbacks(): void;
merge(toMerge: Partial<ActiveVisit>): void;
protected wrapCallback(params: ActiveVisit, name: keyof VisitCallbacks): (...args: any[]) => void;
protected recordCallback(name: keyof VisitCallbacks, args: any[]): void;
static resolvePreserveOption(value: PreserveStateOption, page: Page): boolean;
}

View file

@ -0,0 +1,20 @@
import { Request } from './request';
export declare class RequestStream {
protected requests: Request[];
protected maxConcurrent: number;
protected interruptible: boolean;
constructor({ maxConcurrent, interruptible }: {
maxConcurrent: number;
interruptible: boolean;
});
send(request: Request): void;
interruptInFlight(): void;
cancelInFlight({ prefetch }?: {
prefetch?: boolean | undefined;
}): void;
protected cancel({ cancelled, interrupted }?: {
cancelled?: boolean | undefined;
interrupted?: boolean | undefined;
}, force?: boolean): void;
protected shouldCancel(): boolean;
}

View file

@ -0,0 +1,2 @@
export declare const FormComponentResetSymbol: unique symbol;
export declare function resetFormFields(formElement: HTMLFormElement, defaults: FormData, fieldNames?: string[]): void;

View file

@ -0,0 +1,44 @@
import { RequestParams } from './requestParams';
import { ActiveVisit, ErrorBag, Errors, HttpResponse, Page } from './types';
export declare class Response {
protected requestParams: RequestParams;
protected response: HttpResponse;
protected originatingPage: Page;
protected wasPrefetched: boolean;
constructor(requestParams: RequestParams, response: HttpResponse, originatingPage: Page);
static create(params: RequestParams, response: HttpResponse, originatingPage: Page): Response;
handlePrefetch(): Promise<void>;
handle(): Promise<void>;
process(): Promise<boolean | void>;
mergeParams(params: ActiveVisit): void;
getPageResponse(): Page;
protected handleNonInertiaResponse(): Promise<boolean | void>;
protected isInertiaResponse(): boolean;
protected hasStatus(status: number): boolean;
protected getHeader(header: string): string;
protected hasHeader(header: string): boolean;
protected isLocationVisit(): boolean;
/**
* @link https://inertiajs.com/redirects#external-redirects
*/
protected locationVisit(url: URL): boolean | void;
protected setPage(): Promise<void>;
protected getDataFromResponse(response: any): any;
protected shouldSetPage(pageResponse: Page): boolean;
protected pageUrl(pageResponse: Page): string;
protected preserveEqualProps(pageResponse: Page): void;
protected mergeProps(pageResponse: Page): void;
/**
* By default, the Laravel adapter shares validation errors via Inertia::always(),
* so responses always include errors, even when empty. Components like
* InfiniteScroll and WhenVisible, as well as loading deferred props,
* perform async requests that should practically never reset errors.
*/
protected shouldPreserveErrors(pageResponse: Page): boolean;
protected mergeOrMatchItems(existingItems: any[], newItems: any[], matchProp: string, matchPropsOn: string[], shouldAppend?: boolean): any[];
protected appendWithMatching(existingItems: any[], newItems: any[], newItemsMap: Map<any, any>, uniqueProperty: string): any[];
protected prependWithMatching(existingItems: any[], newItems: any[], newItemsMap: Map<any, any>, uniqueProperty: string): any[];
protected hasUniqueProperty(item: any, property: string): boolean;
protected setRememberedState(pageResponse: Page): Promise<void>;
protected getScopedErrors(errors: Errors & ErrorBag): Errors;
}

View file

@ -0,0 +1,63 @@
import Queue from './queue';
import { RequestStream } from './requestStream';
import { ActiveVisit, ClientSideVisitOptions, Component, FlashData, GlobalEvent, GlobalEventNames, GlobalEventResult, InFlightPrefetch, OptimisticCallback, Page, PageFlashData, PendingVisit, PollOptions, PrefetchedResponse, PrefetchOptions, ReloadOptions, RequestPayload, RouterInitParams, UrlMethodPair, VisitCallbacks, VisitHelperOptions, VisitOptions } from './types';
export declare class Router {
protected syncRequestStream: RequestStream;
protected asyncRequestStream: RequestStream;
protected clientVisitQueue: Queue<Promise<void>>;
protected pendingOptimisticCallback: OptimisticCallback | null;
init<ComponentType = Component>({ initialPage, resolveComponent, swapComponent, onFlash, }: RouterInitParams<ComponentType>): void;
optimistic<TProps>(callback: OptimisticCallback<TProps>): this;
get<T extends RequestPayload = RequestPayload>(url: URL | string | UrlMethodPair, data?: T, options?: VisitHelperOptions<T>): void;
post<T extends RequestPayload = RequestPayload>(url: URL | string | UrlMethodPair, data?: T, options?: VisitHelperOptions<T>): void;
put<T extends RequestPayload = RequestPayload>(url: URL | string | UrlMethodPair, data?: T, options?: VisitHelperOptions<T>): void;
patch<T extends RequestPayload = RequestPayload>(url: URL | string | UrlMethodPair, data?: T, options?: VisitHelperOptions<T>): void;
delete<T extends RequestPayload = RequestPayload>(url: URL | string | UrlMethodPair, options?: Omit<VisitOptions<T>, 'method'>): void;
reload<T extends RequestPayload = RequestPayload>(options?: ReloadOptions<T>): void;
protected doReload<T extends RequestPayload = RequestPayload>(options?: ReloadOptions<T> & {
deferredProps?: boolean;
}): void;
remember(data: unknown, key?: string): void;
restore<T = unknown>(key?: string): T | undefined;
on<TEventName extends GlobalEventNames>(type: TEventName, callback: (event: GlobalEvent<TEventName>) => GlobalEventResult<TEventName>): VoidFunction;
/**
* @deprecated Use cancelAll() instead.
*/
cancel(): void;
cancelAll({ async, prefetch, sync }?: {
async?: boolean | undefined;
prefetch?: boolean | undefined;
sync?: boolean | undefined;
}): void;
poll(interval: number, requestOptions?: ReloadOptions, options?: PollOptions): {
stop: VoidFunction;
start: VoidFunction;
};
visit<T extends RequestPayload = RequestPayload>(href: string | URL | UrlMethodPair, options?: VisitOptions<T>): void;
getCached(href: string | URL | UrlMethodPair, options?: VisitOptions): InFlightPrefetch | PrefetchedResponse | null;
flush(href: string | URL | UrlMethodPair, options?: VisitOptions): void;
flushAll(): void;
flushByCacheTags(tags: string | string[]): void;
getPrefetching(href: string | URL | UrlMethodPair, options?: VisitOptions): InFlightPrefetch | PrefetchedResponse | null;
prefetch(href: string | URL | UrlMethodPair, options?: VisitOptions, prefetchOptions?: Partial<PrefetchOptions>): void;
clearHistory(): void;
decryptHistory(): Promise<Page>;
resolveComponent(component: string, page?: Page): Promise<Component>;
replace<TProps = Page['props']>(params: ClientSideVisitOptions<TProps>): void;
replaceProp<TProps = Page['props']>(name: string, value: unknown | ((oldValue: unknown, props: TProps) => unknown), options?: Pick<ClientSideVisitOptions, 'onError' | 'onFinish' | 'onSuccess'>): void;
appendToProp<TProps = Page['props']>(name: string, value: unknown | unknown[] | ((oldValue: unknown, props: TProps) => unknown | unknown[]), options?: Pick<ClientSideVisitOptions, 'onError' | 'onFinish' | 'onSuccess'>): void;
prependToProp<TProps = Page['props']>(name: string, value: unknown | unknown[] | ((oldValue: unknown, props: TProps) => unknown | unknown[]), options?: Pick<ClientSideVisitOptions, 'onError' | 'onFinish' | 'onSuccess'>): void;
push<TProps = Page['props']>(params: ClientSideVisitOptions<TProps>): void;
flash<TFlash extends PageFlashData = PageFlashData>(keyOrData: string | ((flash: FlashData) => TFlash) | TFlash, value?: unknown): void;
protected clientVisit<TProps = Page['props']>(params: ClientSideVisitOptions<TProps>, { replace }?: {
replace?: boolean;
}): void;
protected performClientVisit<TProps = Page['props']>(params: ClientSideVisitOptions<TProps>, { replace }?: {
replace?: boolean;
}): Promise<void>;
protected getPrefetchParams(href: string | URL | UrlMethodPair, options: VisitOptions): ActiveVisit;
protected getPendingVisit(href: string | URL | UrlMethodPair, options: VisitOptions): PendingVisit;
protected getVisitEvents(options: VisitOptions): VisitCallbacks;
protected applyOptimisticUpdate(optimistic: OptimisticCallback, events: VisitCallbacks): void;
protected loadDeferredProps(deferred: Page['deferredProps']): void;
}

View file

@ -0,0 +1,14 @@
import { ScrollRegion } from './types';
export declare class Scroll {
static save(): void;
static getScrollRegions(): ScrollRegion[];
protected static regions(): NodeListOf<Element>;
static scrollToTop(): void;
static reset(): void;
static scrollToAnchor(): void;
static restore(scrollRegions: ScrollRegion[]): void;
static restoreScrollRegions(scrollRegions: ScrollRegion[]): void;
static restoreDocument(): void;
static onScroll(event: Event): void;
static onWindowScroll(): void;
}

View file

@ -0,0 +1,11 @@
import { InertiaAppResponse, Page } from './types';
export { BROWSER_APIS, type ClassifiedSSRError, type SSRErrorType } from './ssrErrors';
type AppCallback = (page: Page) => InertiaAppResponse;
type ServerOptions = {
port?: number;
cluster?: boolean;
handleErrors?: boolean;
};
type Port = number;
declare const _default: (render: AppCallback, options?: Port | ServerOptions) => AppCallback;
export default _default;

View file

@ -0,0 +1,10 @@
export declare class SessionStorage {
static locationVisitKey: string;
static set(key: string, value: any): void;
static get(key: string): any;
static merge(key: string, value: any): void;
static remove(key: string): void;
static removeNested(key: string, nestedKey: string): void;
static exists(key: string): boolean;
static clear(): void;
}

View file

@ -0,0 +1,28 @@
/**
* SSR Error Classification for Production Server
*
* This module detects common SSR errors and provides helpful hints
* to developers on how to fix them.
*/
export type SSRErrorType = 'browser-api' | 'component-resolution' | 'render' | 'unknown';
type SourceMapResolver = (file: string, line: number, column: number) => {
file: string;
line: number;
column: number;
} | null;
export declare function setSourceMapResolver(resolver: SourceMapResolver | null): void;
export interface ClassifiedSSRError {
error: string;
type: SSRErrorType;
component?: string;
url?: string;
browserApi?: string;
hint: string;
stack?: string;
sourceLocation?: string;
timestamp: string;
}
export declare const BROWSER_APIS: Record<string, string>;
export declare function classifySSRError(error: Error, component?: string, url?: string): ClassifiedSSRError;
export declare function formatConsoleError(classified: ClassifiedSSRError): string;
export {};

View file

@ -0,0 +1,2 @@
import type { Page } from './types';
export declare function buildSSRBody(id: string, page: Page, html: string): string;

View file

@ -0,0 +1,2 @@
import { CacheForOption } from './types';
export declare const timeToMs: (time: CacheForOption) => number;

View file

@ -0,0 +1,649 @@
import { NamedInputEvent, ValidationConfig, Validator } from 'laravel-precognition';
import type { HttpCancelledError, HttpNetworkError, HttpResponseError } from './httpErrors';
import { Response } from './response';
export type HttpRequestHeaders = Record<string, unknown>;
export type HttpResponseHeaders = Record<string, string>;
export interface HttpProgressEvent {
progress: number | undefined;
loaded: number;
total: number | undefined;
percentage?: number;
}
export interface HttpRequestConfig {
method: Method;
url: string;
data?: unknown;
params?: Record<string, unknown>;
headers?: HttpRequestHeaders;
signal?: AbortSignal;
onUploadProgress?: (event: HttpProgressEvent) => void;
}
export interface HttpResponse {
status: number;
data: string;
headers: HttpResponseHeaders;
}
export interface HttpClient {
request(config: HttpRequestConfig): Promise<HttpResponse>;
}
export interface HttpClientOptions {
xsrfCookieName?: string;
xsrfHeaderName?: string;
}
export type HttpRequestHandler = (config: HttpRequestConfig) => HttpRequestConfig | Promise<HttpRequestConfig>;
export type HttpResponseHandler = (response: HttpResponse) => HttpResponse | Promise<HttpResponse>;
export type HttpErrorHandler = (error: HttpResponseError | HttpNetworkError | HttpCancelledError) => void | Promise<void>;
export interface PageFlashData {
[key: string]: unknown;
}
export type DefaultInertiaConfig = {
errorValueType: string;
flashDataType: PageFlashData;
sharedPageProps: PageProps;
};
/**
* Designed to allow overriding of some core types using TypeScript
* interface declaration merging.
*
* @see {@link DefaultInertiaConfig} for keys to override
* @example
* ```ts
* declare module '@inertiajs/core' {
* export interface InertiaConfig {
* errorValueType: string[]
* flashDataType: {
* toast?: { type: 'success' | 'error', message: string }
* }
* sharedPageProps: {
* auth: { user: User | null }
* }
* }
* }
* ```
*/
export interface InertiaConfig {
}
export type InertiaConfigFor<Key extends keyof DefaultInertiaConfig> = Key extends keyof InertiaConfig ? InertiaConfig[Key] : DefaultInertiaConfig[Key];
export type ErrorValue = InertiaConfigFor<'errorValueType'>;
export type FlashData = InertiaConfigFor<'flashDataType'>;
export type SharedPageProps = InertiaConfigFor<'sharedPageProps'>;
export type Errors = Record<string, ErrorValue>;
export type ErrorBag = Record<string, Errors>;
export type FormDataConvertibleValue = Blob | FormDataEntryValue | Date | boolean | number | null | undefined;
export type FormDataConvertible = Array<FormDataConvertible> | {
[key: string]: FormDataConvertible;
} | FormDataConvertibleValue;
export type FormDataType<T extends object> = {
[K in keyof T]: T[K] extends infer U ? U extends FormDataConvertibleValue ? U : U extends (...args: unknown[]) => unknown ? never : U extends object | Array<unknown> ? FormDataType<U> : never : never;
};
/**
* Uses `0 extends 1 & T` to detect `any` type and prevent infinite recursion.
*/
export type FormDataKeys<T> = T extends Function | FormDataConvertibleValue ? never : T extends unknown[] ? ArrayFormDataKeys<T> : T extends object ? ObjectFormDataKeys<T> : never;
/**
* Helper type for array form data keys
*/
type ArrayFormDataKeys<T extends unknown[]> = number extends T['length'] ? `${number}` | (0 extends 1 & T[number] ? never : T[number] extends FormDataConvertibleValue ? never : `${number}.${FormDataKeys<T[number]>}`) : Extract<keyof T, `${number}`> | {
[Key in Extract<keyof T, `${number}`>]: 0 extends 1 & T[Key] ? never : T[Key] extends FormDataConvertibleValue ? never : `${Key & string}.${FormDataKeys<T[Key & string] & string>}`;
}[Extract<keyof T, `${number}`>];
/**
* Helper type for object form data keys
*/
type ObjectFormDataKeys<T extends object> = string extends keyof T ? string : Extract<keyof T, string> | {
[Key in Extract<keyof T, string>]: 0 extends 1 & T[Key] ? never : T[Key] extends FormDataConvertibleValue ? never : T[Key] extends any[] ? `${Key}.${FormDataKeys<T[Key]> & string}` : T[Key] extends Record<string, any> ? `${Key}.${FormDataKeys<T[Key]> & string}` : Exclude<T[Key], null | undefined> extends any[] ? never : Exclude<T[Key], null | undefined> extends Record<string, any> ? `${Key}.${FormDataKeys<Exclude<T[Key], null | undefined>> & string}` : never;
}[Extract<keyof T, string>];
type PartialFormDataErrors<T> = {
[K in string extends keyof T ? string : Extract<keyof FormDataError<T>, string>]?: ErrorValue;
};
export type FormDataErrors<T> = PartialFormDataErrors<T> & {
[K in keyof PartialFormDataErrors<T>]: NonNullable<PartialFormDataErrors<T>[K]>;
};
export type FormDataValues<T, K extends FormDataKeys<T>> = K extends `${infer P}.${infer Rest}` ? T extends unknown[] ? P extends `${infer I extends number}` ? Rest extends FormDataKeys<T[I]> ? FormDataValues<T[I], Rest> : never : never : P extends keyof T ? Rest extends FormDataKeys<T[P]> ? FormDataValues<T[P], Rest> : never : never : K extends keyof T ? T[K] : T extends unknown[] ? T[K & number] : never;
export type FormDataError<T> = Partial<Record<FormDataKeys<T>, ErrorValue>>;
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
export type RequestPayload = Record<string, FormDataConvertible> | FormData;
export interface PageProps {
[key: string]: unknown;
}
export type ScrollProp = {
pageName: string;
previousPage: number | string | null;
nextPage: number | string | null;
currentPage: number | string | null;
reset: boolean;
};
export interface Page<SharedProps extends PageProps = PageProps> {
component: string;
props: PageProps & SharedProps & {
errors: Errors & ErrorBag;
deferred?: Record<string, VisitOptions['only']>;
};
url: string;
version: string | null;
clearHistory: boolean;
encryptHistory: boolean;
deferredProps?: Record<string, NonNullable<VisitOptions['only']>>;
initialDeferredProps?: Record<string, NonNullable<VisitOptions['only']>>;
mergeProps?: string[];
prependProps?: string[];
deepMergeProps?: string[];
matchPropsOn?: string[];
scrollProps?: Record<keyof PageProps, ScrollProp>;
flash: FlashData;
onceProps?: Record<string, {
prop: keyof PageProps;
expiresAt?: number | null;
}>;
/** @internal */
rememberedState: Record<string, unknown>;
}
export type ScrollRegion = {
top: number;
left: number;
};
export interface ClientSideVisitOptions<TProps = Page['props']> {
component?: Page['component'];
url?: Page['url'];
props?: ((props: TProps, onceProps: Partial<TProps>) => PageProps) | PageProps;
flash?: ((flash: FlashData) => PageFlashData) | PageFlashData;
clearHistory?: Page['clearHistory'];
encryptHistory?: Page['encryptHistory'];
preserveScroll?: VisitOptions['preserveScroll'];
preserveState?: VisitOptions['preserveState'];
errorBag?: string | null;
viewTransition?: VisitOptions['viewTransition'];
onError?: (errors: Errors) => void;
onFinish?: (visit: ClientSideVisitOptions<TProps>) => void;
onFlash?: (flash: FlashData) => void;
onSuccess?: (page: Page) => void;
}
export type PageResolver = (name: string, page?: Page) => Component;
export type PageHandler<ComponentType = Component> = ({ component, page, preserveState, }: {
component: ComponentType;
page: Page;
preserveState: boolean;
}) => Promise<unknown>;
export type PreserveStateOption = boolean | 'errors' | ((page: Page) => boolean);
export type QueryStringArrayFormatOption = 'indices' | 'brackets';
export type Progress = HttpProgressEvent;
export type LocationVisit = {
preserveScroll: boolean;
};
export type CancelToken = {
cancel: VoidFunction;
};
export type CancelTokenCallback = (cancelToken: CancelToken) => void;
export type OptimisticCallback<TProps = Page['props']> = (props: TProps) => Partial<TProps> | void;
export type Visit<T extends RequestPayload = RequestPayload> = {
method: Method;
data: T;
replace: boolean;
preserveScroll: PreserveStateOption;
preserveState: PreserveStateOption;
only: Array<string>;
except: Array<string>;
headers: Record<string, string>;
errorBag: string | null;
forceFormData: boolean;
queryStringArrayFormat: QueryStringArrayFormatOption;
async: boolean;
showProgress: boolean;
prefetch: boolean;
fresh: boolean;
reset: string[];
preserveUrl: boolean;
preserveErrors: boolean;
invalidateCacheTags: string | string[];
viewTransition: boolean | ((viewTransition: ViewTransition) => void);
optimistic?: OptimisticCallback;
};
export type GlobalEventsMap<T extends RequestPayload = RequestPayload> = {
before: {
parameters: [PendingVisit<T>];
details: {
visit: PendingVisit<T>;
};
result: boolean | void;
};
start: {
parameters: [PendingVisit<T>];
details: {
visit: PendingVisit<T>;
};
result: void;
};
progress: {
parameters: [Progress | undefined];
details: {
progress: Progress | undefined;
};
result: void;
};
finish: {
parameters: [ActiveVisit<T>];
details: {
visit: ActiveVisit<T>;
};
result: void;
};
cancel: {
parameters: [];
details: {};
result: void;
};
beforeUpdate: {
parameters: [Page];
details: {
page: Page;
};
result: void;
};
navigate: {
parameters: [Page];
details: {
page: Page;
};
result: void;
};
success: {
parameters: [Page];
details: {
page: Page;
};
result: void;
};
error: {
parameters: [Errors];
details: {
errors: Errors;
};
result: void;
};
httpException: {
parameters: [HttpResponse];
details: {
response: HttpResponse;
};
result: boolean | void;
};
networkError: {
parameters: [Error];
details: {
exception: Error;
};
result: boolean | void;
};
prefetched: {
parameters: [HttpResponse, ActiveVisit<T>];
details: {
response: HttpResponse;
fetchedAt: number;
visit: ActiveVisit<T>;
};
result: void;
};
prefetching: {
parameters: [ActiveVisit<T>];
details: {
visit: ActiveVisit<T>;
};
result: void;
};
flash: {
parameters: [Page['flash']];
details: {
flash: Page['flash'];
};
result: void;
};
};
export type PageEvent = 'newComponent' | 'firstLoad';
export type GlobalEventNames<T extends RequestPayload = RequestPayload> = keyof GlobalEventsMap<T>;
export type GlobalEvent<TEventName extends GlobalEventNames<T>, T extends RequestPayload = RequestPayload> = CustomEvent<GlobalEventDetails<TEventName, T>>;
export type GlobalEventParameters<TEventName extends GlobalEventNames<T>, T extends RequestPayload = RequestPayload> = GlobalEventsMap<T>[TEventName]['parameters'];
export type GlobalEventResult<TEventName extends GlobalEventNames<T>, T extends RequestPayload = RequestPayload> = GlobalEventsMap<T>[TEventName]['result'];
export type GlobalEventDetails<TEventName extends GlobalEventNames<T>, T extends RequestPayload = RequestPayload> = GlobalEventsMap<T>[TEventName]['details'];
export type GlobalEventTrigger<TEventName extends GlobalEventNames<T>, T extends RequestPayload = RequestPayload> = (...params: GlobalEventParameters<TEventName, T>) => GlobalEventResult<TEventName, T>;
export type GlobalEventCallback<TEventName extends GlobalEventNames<T>, T extends RequestPayload = RequestPayload> = (...params: GlobalEventParameters<TEventName, T>) => GlobalEventResult<TEventName, T>;
export type InternalEvent = 'missingHistoryItem' | 'loadDeferredProps' | 'historyQuotaExceeded';
export type VisitCallbacks<T extends RequestPayload = RequestPayload> = {
onCancelToken: CancelTokenCallback;
onBefore: GlobalEventCallback<'before', T>;
onBeforeUpdate: GlobalEventCallback<'beforeUpdate', T>;
onStart: GlobalEventCallback<'start', T>;
onProgress: GlobalEventCallback<'progress', T>;
onFinish: GlobalEventCallback<'finish', T>;
onCancel: GlobalEventCallback<'cancel', T>;
onSuccess: GlobalEventCallback<'success', T>;
onError: GlobalEventCallback<'error', T>;
onHttpException: GlobalEventCallback<'httpException', T>;
onNetworkError: GlobalEventCallback<'networkError', T>;
onFlash: GlobalEventCallback<'flash', T>;
onPrefetched: GlobalEventCallback<'prefetched', T>;
onPrefetching: GlobalEventCallback<'prefetching', T>;
};
export type VisitOptions<T extends RequestPayload = RequestPayload> = Partial<Visit<T> & VisitCallbacks<T>>;
export type ReloadOptions<T extends RequestPayload = RequestPayload> = Omit<VisitOptions<T>, 'preserveScroll' | 'preserveState'>;
export type PollOptions = {
keepAlive?: boolean;
autoStart?: boolean;
};
export type VisitHelperOptions<T extends RequestPayload = RequestPayload> = Omit<VisitOptions<T>, 'method' | 'data'>;
export type RouterInitParams<ComponentType = Component> = {
initialPage: Page;
resolveComponent: PageResolver;
swapComponent: PageHandler<ComponentType>;
onFlash?: (flash: Page['flash']) => void;
};
export type PendingVisitOptions = {
url: URL;
completed: boolean;
cancelled: boolean;
interrupted: boolean;
};
export type PendingVisit<T extends RequestPayload = RequestPayload> = Visit<T> & PendingVisitOptions;
export type ActiveVisit<T extends RequestPayload = RequestPayload> = PendingVisit<T> & Required<Omit<VisitOptions<T>, 'optimistic'>>;
export type InternalActiveVisit = ActiveVisit & {
onPrefetchResponse?: (response: Response) => void;
onPrefetchError?: (error: Error) => void;
deferredProps?: boolean;
};
export type VisitId = unknown;
export type Component = unknown;
type FirstLevelOptional<T> = {
[K in keyof T]?: T[K] extends object ? {
[P in keyof T[K]]?: T[K][P];
} : T[K];
};
type PagesOption = string | {
path: string;
extension?: string | string[];
transform?: (name: string) => string;
};
type ProgressOptions = {
delay?: number;
color?: string;
includeCSS?: boolean;
showSpinner?: boolean;
};
interface BaseCreateInertiaAppOptions<TComponentResolver, TSetupOptions, TSetupReturn, TAdditionalInertiaAppConfig> {
resolve: TComponentResolver;
pages?: PagesOption;
layout?: (name: string, page: Page) => unknown;
setup: (options: TSetupOptions) => TSetupReturn;
title?: HeadManagerTitleCallback;
defaults?: FirstLevelOptional<InertiaAppConfig & TAdditionalInertiaAppConfig>;
/** HTTP client or options to use for requests. Defaults to XhrHttpClient. */
http?: HttpClient | HttpClientOptions;
}
export interface CreateInertiaAppOptionsForCSR<SharedProps extends PageProps, TComponentResolver, TSetupOptions, TSetupReturn, TAdditionalInertiaAppConfig> extends BaseCreateInertiaAppOptions<TComponentResolver, TSetupOptions, TSetupReturn, TAdditionalInertiaAppConfig> {
id?: string;
page?: Page<SharedProps>;
progress?: ProgressOptions | false;
render?: undefined;
}
export interface CreateInertiaAppOptionsForSSR<SharedProps extends PageProps, TComponentResolver, TSetupOptions, TSetupReturn, TAdditionalInertiaAppConfig> extends BaseCreateInertiaAppOptions<TComponentResolver, TSetupOptions, TSetupReturn, TAdditionalInertiaAppConfig> {
id?: undefined;
page: Page<SharedProps>;
progress?: undefined;
render: unknown;
}
export type InertiaAppSSRResponse = {
head: string[];
body: string;
};
export type InertiaAppResponse = Promise<InertiaAppSSRResponse | void>;
export type HeadManagerTitleCallback = (title: string) => string;
export interface CreateInertiaAppOptions<TComponentResolver, TSetupOptions, TSetupReturn, TAdditionalInertiaAppConfig> {
id?: string;
resolve?: TComponentResolver;
pages?: PagesOption;
layout?: (name: string, page: Page) => unknown;
setup?: (options: TSetupOptions) => TSetupReturn;
title?: HeadManagerTitleCallback;
progress?: ProgressOptions | false;
defaults?: FirstLevelOptional<InertiaAppConfig & TAdditionalInertiaAppConfig>;
/** HTTP client or options to use for requests. Defaults to XhrHttpClient. */
http?: HttpClient | HttpClientOptions;
}
export type HeadManagerOnUpdateCallback = (elements: string[]) => void;
export type HeadManager = {
forceUpdate: () => void;
createProvider: () => {
reconnect: () => void;
update: HeadManagerOnUpdateCallback;
disconnect: () => void;
};
};
export type LinkPrefetchOption = 'mount' | 'hover' | 'click';
export type TimeUnit = 'ms' | 's' | 'm' | 'h' | 'd';
export type CacheForOption = number | `${number}${TimeUnit}` | string;
export type PrefetchOptions = {
cacheFor: CacheForOption | CacheForOption[];
cacheTags: string | string[];
};
export type InertiaAppConfig = {
form: {
recentlySuccessfulDuration: number;
forceIndicesArrayFormatInFormData: boolean;
withAllErrors: boolean;
};
prefetch: {
cacheFor: CacheForOption | CacheForOption[];
hoverDelay: number;
};
visitOptions?: (href: string, options: VisitOptions) => VisitOptions;
};
export interface LinkComponentBaseProps extends Partial<Pick<Visit<RequestPayload>, 'data' | 'method' | 'replace' | 'preserveScroll' | 'preserveState' | 'preserveUrl' | 'only' | 'except' | 'headers' | 'queryStringArrayFormat' | 'async' | 'viewTransition'> & VisitCallbacks & {
href: string | UrlMethodPair;
prefetch: boolean | LinkPrefetchOption | LinkPrefetchOption[];
cacheFor: CacheForOption | CacheForOption[];
cacheTags: string | string[];
}> {
}
type PrefetchObject = {
params: ActiveVisit;
response: Promise<Response>;
};
export type InFlightPrefetch = PrefetchObject & {
staleTimestamp: null;
inFlight: true;
};
export type PrefetchCancellationToken = {
isCancelled: boolean;
cancel: () => void;
};
export type PrefetchedResponse = PrefetchObject & {
staleTimestamp: number;
timestamp: number;
expiresAt: number;
singleUse: boolean;
inFlight: false;
tags: string[];
};
export type PrefetchRemovalTimer = {
params: ActiveVisit;
timer: number;
};
export type ProgressSettings = {
minimum: number;
easing: string;
positionUsing: 'translate3d' | 'translate' | 'margin';
speed: number;
trickle: boolean;
trickleSpeed: number;
showSpinner: boolean;
barSelector: string;
spinnerSelector: string;
parent: string;
template: string;
includeCSS: boolean;
color: string;
};
export type UrlMethodPair = {
url: string;
method: Method;
};
export type UseFormTransformCallback<TForm> = (data: TForm) => object;
export type UseFormWithPrecognitionArguments = [Method | (() => Method), string | (() => string)] | [UrlMethodPair | (() => UrlMethodPair)];
type UseFormInertiaArguments<TForm> = [] | [data: TForm | (() => TForm)] | [rememberKey: string, data: TForm | (() => TForm)];
type UseFormPrecognitionArguments<TForm> = [urlMethodPair: UrlMethodPair | (() => UrlMethodPair), data: TForm | (() => TForm)] | [method: Method | (() => Method), url: string | (() => string), data: TForm | (() => TForm)];
export type UseFormArguments<TForm> = UseFormInertiaArguments<TForm> | UseFormPrecognitionArguments<TForm>;
export type UseFormSubmitOptions = Omit<VisitOptions, 'data'>;
export type UseFormSubmitArguments = [Method, string, UseFormSubmitOptions?] | [UrlMethodPair, UseFormSubmitOptions?] | [UseFormSubmitOptions?];
export type UseHttpSubmitArguments<TResponse = unknown, TForm = unknown> = [Method, string, UseHttpSubmitOptions<TResponse, TForm>?] | [UrlMethodPair, UseHttpSubmitOptions<TResponse, TForm>?] | [UseHttpSubmitOptions<TResponse, TForm>?];
export type FormComponentOptions = Pick<VisitOptions, 'preserveScroll' | 'preserveState' | 'preserveUrl' | 'replace' | 'only' | 'except' | 'reset' | 'viewTransition'>;
export type FormComponentOptimisticCallback<TProps = Page['props']> = (props: TProps, formData: Record<string, FormDataConvertible>) => Partial<TProps> | void;
export type FormComponentProps = Partial<Pick<Visit, 'headers' | 'queryStringArrayFormat' | 'errorBag' | 'showProgress' | 'invalidateCacheTags'> & Omit<VisitCallbacks, 'onPrefetched' | 'onPrefetching'>> & {
method?: Method | Uppercase<Method>;
action?: string | UrlMethodPair;
transform?: (data: Record<string, FormDataConvertible>) => Record<string, FormDataConvertible>;
optimistic?: FormComponentOptimisticCallback;
options?: FormComponentOptions;
onSubmitComplete?: (props: FormComponentonSubmitCompleteArguments) => void;
disableWhileProcessing?: boolean;
resetOnSuccess?: boolean | string[];
resetOnError?: boolean | string[];
setDefaultsOnSuccess?: boolean;
validateFiles?: boolean;
validationTimeout?: number;
withAllErrors?: boolean | null;
};
export type FormComponentMethods<TForm extends object = Record<string, any>> = {
clearErrors: <K extends FormDataKeys<TForm>>(...fields: K[]) => void;
resetAndClearErrors: <K extends FormDataKeys<TForm>>(...fields: K[]) => void;
setError: {
<K extends FormDataKeys<TForm>>(field: K, value: ErrorValue): void;
(errors: FormDataErrors<TForm>): void;
};
reset: <K extends FormDataKeys<TForm>>(...fields: K[]) => void;
submit: () => void;
defaults: () => void;
getData: () => TForm;
getFormData: () => FormData;
valid: <K extends FormDataKeys<TForm>>(field: K) => boolean;
invalid: <K extends FormDataKeys<TForm>>(field: K) => boolean;
validate: <K extends FormDataKeys<TForm>>(field?: K | NamedInputEvent | ValidationConfig, config?: ValidationConfig) => void;
touch: <K extends FormDataKeys<TForm>>(...fields: K[]) => void;
touched: <K extends FormDataKeys<TForm>>(field?: K) => boolean;
validator: () => Validator;
};
export type FormComponentonSubmitCompleteArguments<TForm extends object = Record<string, any>> = Pick<FormComponentMethods<TForm>, 'reset' | 'defaults'>;
export type FormComponentState<TForm extends object = Record<string, any>> = {
errors: FormDataErrors<TForm>;
hasErrors: boolean;
processing: boolean;
progress: Progress | null;
wasSuccessful: boolean;
recentlySuccessful: boolean;
isDirty: boolean;
validating: boolean;
};
export type FormComponentSlotProps<TForm extends object = Record<string, any>> = FormComponentMethods<TForm> & FormComponentState<TForm>;
export type FormComponentRef<TForm extends object = Record<string, any>> = FormComponentSlotProps<TForm>;
export interface UseInfiniteScrollOptions {
getPropName: () => string;
inReverseMode: () => boolean;
shouldFetchNext: () => boolean;
shouldFetchPrevious: () => boolean;
shouldPreserveUrl: () => boolean;
getReloadOptions?: () => ReloadOptions;
getTriggerMargin: () => number;
getStartElement: () => HTMLElement;
getEndElement: () => HTMLElement;
getItemsElement: () => HTMLElement;
getScrollableParent: () => HTMLElement | null;
onBeforePreviousRequest: () => void;
onBeforeNextRequest: () => void;
onCompletePreviousRequest: () => void;
onCompleteNextRequest: () => void;
onDataReset?: () => void;
}
export interface UseInfiniteScrollDataManager {
getLastLoadedPage: () => number | string | null;
getPageName: () => string;
getRequestCount: () => number;
hasPrevious: () => boolean;
hasNext: () => boolean;
fetchNext: (reloadOptions?: ReloadOptions) => void;
fetchPrevious: (reloadOptions?: ReloadOptions) => void;
removeEventListener: () => void;
}
export interface UseInfiniteScrollElementManager {
setupObservers: () => void;
enableTriggers: () => void;
disableTriggers: () => void;
refreshTriggers: () => void;
flushAll: () => void;
processManuallyAddedElements: () => void;
processServerLoadedElements: (loadedPage: string | number | null) => void;
}
export interface UseInfiniteScrollProps {
dataManager: UseInfiniteScrollDataManager;
elementManager: UseInfiniteScrollElementManager;
flush: () => void;
}
export interface InfiniteScrollSlotProps {
loading: boolean;
loadingPrevious: boolean;
loadingNext: boolean;
}
export interface InfiniteScrollActionSlotProps {
loading: boolean;
loadingPrevious: boolean;
loadingNext: boolean;
fetch: () => void;
autoMode: boolean;
manualMode: boolean;
hasMore: boolean;
hasPrevious: boolean;
hasNext: boolean;
}
export interface InfiniteScrollRef {
fetchNext: (reloadOptions?: ReloadOptions) => void;
fetchPrevious: (reloadOptions?: ReloadOptions) => void;
hasPrevious: () => boolean;
hasNext: () => boolean;
}
export interface InfiniteScrollComponentBaseProps {
data: string;
buffer?: number;
as?: string;
manual?: boolean;
manualAfter?: number;
preserveUrl?: boolean;
reverse?: boolean;
autoScroll?: boolean;
onlyNext?: boolean;
onlyPrevious?: boolean;
}
export type UseHttpOptions<TResponse = unknown> = {
onBefore?: () => boolean | void;
onStart?: () => void;
onProgress?: (progress: HttpProgressEvent) => void;
onSuccess?: (response: TResponse) => void;
onError?: (errors: Errors) => void;
onFinish?: () => void;
onCancel?: () => void;
onCancelToken?: (cancelToken: CancelToken) => void;
};
export type UseHttpSubmitOptions<TResponse = unknown, TForm = unknown> = UseHttpOptions<TResponse> & {
headers?: HttpRequestHeaders;
optimistic?: (currentData: TForm) => Partial<TForm>;
};
declare global {
interface DocumentEventMap {
'inertia:before': GlobalEvent<'before'>;
'inertia:start': GlobalEvent<'start'>;
'inertia:progress': GlobalEvent<'progress'>;
'inertia:success': GlobalEvent<'success'>;
'inertia:error': GlobalEvent<'error'>;
'inertia:httpException': GlobalEvent<'httpException'>;
'inertia:networkError': GlobalEvent<'networkError'>;
'inertia:finish': GlobalEvent<'finish'>;
'inertia:beforeUpdate': GlobalEvent<'beforeUpdate'>;
'inertia:navigate': GlobalEvent<'navigate'>;
'inertia:flash': GlobalEvent<'flash'>;
}
}
export {};

View file

@ -0,0 +1,13 @@
import type { FormDataConvertible, Method, QueryStringArrayFormatOption, RequestPayload, UrlMethodPair, VisitOptions } from './types';
export declare function hrefToUrl(href: string | URL): URL;
export declare const transformUrlAndData: (href: string | URL, data: RequestPayload, method: Method, forceFormData: VisitOptions["forceFormData"], queryStringArrayFormat: VisitOptions["queryStringArrayFormat"]) => [URL, RequestPayload];
type MergeDataIntoQueryStringDataReturnType<T extends RequestPayload> = T extends Record<string, FormDataConvertible> ? Record<string, FormDataConvertible> : RequestPayload;
export declare function mergeDataIntoQueryString<T extends RequestPayload>(method: Method, href: URL | string, data: T, qsArrayFormat?: QueryStringArrayFormatOption): [string, MergeDataIntoQueryStringDataReturnType<T>];
export declare function urlWithoutHash(url: URL | Location): URL;
export declare const setHashIfSameUrl: (originUrl: URL | Location, destinationUrl: URL | Location) => void;
export declare const isSameUrlWithoutHash: (url1: URL | Location, url2: URL | Location) => boolean;
export declare const isSameUrlWithoutQueryOrHash: (url1: URL | Location, url2: URL | Location) => boolean;
export declare function isUrlMethodPair(href: unknown): href is UrlMethodPair;
export declare function urlHasProtocol(url: string): boolean;
export declare function urlToString(url: URL | string, absolute: boolean): string;
export {};

View file

@ -0,0 +1,48 @@
import { NamedInputEvent, ValidationConfig } from 'laravel-precognition';
import { FormDataType, Method, UrlMethodPair, UseFormArguments, UseFormSubmitArguments, UseFormSubmitOptions } from './types';
export declare class UseFormUtils {
/**
* Creates a callback that returns a UrlMethodPair.
*
* createWayfinderCallback(urlMethodPair)
* createWayfinderCallback(method, url)
* createWayfinderCallback(() => urlMethodPair)
* createWayfinderCallback(() => method, () => url)
*/
static createWayfinderCallback(...args: [UrlMethodPair | (() => UrlMethodPair)] | [Method | (() => Method), string | (() => string)]): () => UrlMethodPair;
/**
* Parses all useForm() arguments into { rememberKey, data, precognitionEndpoint }.
*
* useForm()
* useForm(data)
* useForm(rememberKey, data)
* useForm(method, url, data)
* useForm(urlMethodPair, data)
*
*/
static parseUseFormArguments<TForm extends FormDataType<TForm>>(...args: UseFormArguments<TForm>): {
rememberKey: string | null;
data: TForm | (() => TForm);
precognitionEndpoint: (() => UrlMethodPair) | null;
};
/**
* Parses all submission arguments into { method, url, options }.
* It uses the Precognition endpoint if no explicit method/url are provided.
*
* form.submit(method, url)
* form.submit(method, url, options)
* form.submit(urlMethodPair)
* form.submit(urlMethodPair, options)
* form.submit()
* form.submit(options)
*/
static parseSubmitArguments(args: UseFormSubmitArguments, precognitionEndpoint: (() => UrlMethodPair) | null): {
method: Method;
url: string;
options: UseFormSubmitOptions;
};
/**
* Merges headers into the Precognition validate() arguments.
*/
static mergeHeadersForValidation(field?: string | NamedInputEvent | ValidationConfig, config?: ValidationConfig, headers?: Record<string, string>): [string | NamedInputEvent | ValidationConfig | undefined, ValidationConfig | undefined];
}

View file

@ -0,0 +1,12 @@
import { HttpClient, HttpClientOptions, HttpRequestConfig, HttpResponse } from './types';
/**
* Inertia's built-in HTTP client using XMLHttpRequest
*/
export declare class XhrHttpClient implements HttpClient {
protected xsrfCookieName: string;
protected xsrfHeaderName: string;
constructor(options?: HttpClientOptions);
request(config: HttpRequestConfig): Promise<HttpResponse>;
protected doRequest(config: HttpRequestConfig): Promise<HttpResponse>;
}
export declare const xhrHttpClient: XhrHttpClient;

View file

@ -0,0 +1,128 @@
<script module lang="ts">
import type { ComponentResolver, ResolvedComponent } from '../types'
import { type Page, type PageProps } from '@inertiajs/core'
export interface InertiaAppProps<SharedProps extends PageProps = PageProps> {
initialComponent: ResolvedComponent
initialPage: Page<SharedProps>
resolveComponent: ComponentResolver
defaultLayout?: (name: string, page: Page) => unknown
}
</script>
<script lang="ts">
import type { Component } from 'svelte'
import type { LayoutType, LayoutResolver } from '../types'
import { normalizeLayouts } from '@inertiajs/core'
import { router } from '@inertiajs/core'
import Render, { h, type RenderProps } from './Render.svelte'
import { setPage } from '../page.svelte'
import { resetLayoutProps } from '../layoutProps.svelte'
interface Props {
initialComponent: InertiaAppProps['initialComponent']
initialPage: InertiaAppProps['initialPage']
resolveComponent: InertiaAppProps['resolveComponent']
defaultLayout?: InertiaAppProps['defaultLayout']
}
const { initialComponent, initialPage, resolveComponent, defaultLayout }: Props = $props()
// svelte-ignore state_referenced_locally
let component = $state(initialComponent)
let key = $state<number | null>(null)
// svelte-ignore state_referenced_locally
let page = $state({ ...initialPage, flash: initialPage.flash ?? {} })
let renderProps = $derived.by<RenderProps>(() => resolveRenderProps(component, page, key))
// Reactively update the global page state when local page state changes
$effect.pre(() => {
setPage(page)
})
const isServer = typeof window === 'undefined'
if (!isServer) {
// svelte-ignore state_referenced_locally
router.init<ResolvedComponent>({
initialPage,
resolveComponent,
swapComponent: async (args) => {
component = args.component
page = args.page
key = args.preserveState ? key : Date.now()
if (!args.preserveState) {
resetLayoutProps()
}
},
onFlash: (flash) => {
page = { ...page, flash }
},
})
}
function isComponent(value: unknown): value is Component {
if (!value) {
return false
}
if (typeof value === 'function') {
const fn = value as Function & { name?: string }
return fn.name !== ''
}
if (typeof value === 'object' && '$$' in value) {
return true
}
return false
}
function isRenderFunction(value: unknown): boolean {
return (
typeof value === 'function' &&
(value as Function).length === 2 &&
typeof (value as Function).prototype === 'undefined'
)
}
function resolveRenderProps(component: ResolvedComponent, page: Page, key: number | null = null): RenderProps {
const child = h(component.default, page.props, [], key)
if (component.layout && isRenderFunction(component.layout)) {
return (component.layout as LayoutResolver)(h, child)
}
const effectiveLayout = (component.layout ?? defaultLayout?.(page.component, page)) as LayoutType | undefined
return effectiveLayout ? resolveLayout(effectiveLayout, child, page.props, key, !!component.layout) : child
}
function resolveLayout(
layout: LayoutType,
child: RenderProps,
pageProps: PageProps,
key: number | null,
isFromPage: boolean = true,
): RenderProps {
if (isFromPage && isRenderFunction(layout)) {
return (layout as LayoutResolver)(h, child)
}
const layouts = normalizeLayouts(layout, isComponent, isFromPage ? isRenderFunction : undefined)
if (layouts.length > 0) {
return layouts.reduceRight((child, layout) => {
return {
...h(layout.component, { ...pageProps, ...layout.props }, [child], key),
name: layout.name,
}
}, child)
}
return child
}
</script>
<Render {...renderProps} />

View file

@ -0,0 +1,18 @@
import type { ComponentResolver, ResolvedComponent } from '../types';
import { type Page, type PageProps } from '@inertiajs/core';
export interface InertiaAppProps<SharedProps extends PageProps = PageProps> {
initialComponent: ResolvedComponent;
initialPage: Page<SharedProps>;
resolveComponent: ComponentResolver;
defaultLayout?: (name: string, page: Page) => unknown;
}
import type { Component } from 'svelte';
interface Props {
initialComponent: InertiaAppProps['initialComponent'];
initialPage: InertiaAppProps['initialPage'];
resolveComponent: InertiaAppProps['resolveComponent'];
defaultLayout?: InertiaAppProps['defaultLayout'];
}
declare const App: Component<Props, {}, "">;
type App = ReturnType<typeof App>;
export default App;

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { isSameUrlWithoutQueryOrHash, router } from '@inertiajs/core'
import { page } from '../index'
interface Props {
data: string | string[]
fallback?: import('svelte').Snippet
children?: import('svelte').Snippet<[{ reloading: boolean }]>
}
let { data, fallback, children }: Props = $props()
const keys = $derived(Array.isArray(data) ? data : [data])
const loaded = $derived(keys.every((key) => typeof page.props[key] !== 'undefined'))
let reloading = $state(false)
const activeReloads = new Set<object>()
const keysAreBeingReloaded = (only: string[], except: string[], keys: string[]): boolean => {
if (only.length === 0 && except.length === 0) {
return true
}
if (only.length > 0) {
return keys.some((key) => only.includes(key))
}
return keys.some((key) => !except.includes(key))
}
$effect(() => {
const removeStartListener = router.on('start', (e) => {
const visit = e.detail.visit
if (
visit.preserveState === true &&
isSameUrlWithoutQueryOrHash(visit.url, window.location) &&
keysAreBeingReloaded(visit.only, visit.except, keys)
) {
activeReloads.add(visit)
reloading = true
}
})
const removeFinishListener = router.on('finish', (e) => {
const visit = e.detail.visit
if (activeReloads.has(visit)) {
activeReloads.delete(visit)
reloading = activeReloads.size > 0
}
})
return () => {
removeStartListener()
removeFinishListener()
activeReloads.clear()
}
})
$effect.pre(() => {
if (!fallback) {
throw new Error('`<Deferred>` requires a `fallback` snippet')
}
})
</script>
{#if loaded}
{@render children?.({ reloading })}
{:else}
{@render fallback?.()}
{/if}

View file

@ -0,0 +1,10 @@
interface Props {
data: string | string[];
fallback?: import('svelte').Snippet;
children?: import('svelte').Snippet<[{
reloading: boolean;
}]>;
}
declare const Deferred: import("svelte").Component<Props, {}, "">;
type Deferred = ReturnType<typeof Deferred>;
export default Deferred;

Some files were not shown because too many files have changed in this diff Show more