mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 19:55:16 +00:00
Modals! New projects page! Better dev imports! Fix OAuth2 projects! (#958)
* Modals! New projects page! * Update modal close buttons * Make progress bar better * Various fixes + tests * Formatting * Fix tests?
This commit is contained in:
parent
044a1e4fea
commit
f3350234f5
38 changed files with 2841 additions and 532 deletions
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -128,9 +128,19 @@ jobs:
|
|||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
PARALLEL_WORKERS: 4
|
||||
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
|
||||
PGHOST: localhost
|
||||
PGUSER: postgres
|
||||
|
|
@ -142,10 +152,21 @@ jobs:
|
|||
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime;" || true
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log;" || true
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;" || true
|
||||
# Create per-worker variants for parallelized tests (e.g., test_wakatime_0)
|
||||
for worker in $(seq 0 $((PARALLEL_WORKERS - 1))); do
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime_${worker};" || true
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log_${worker};" || true
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse_${worker};" || true
|
||||
done
|
||||
# Mirror schema from primary test DB so cross-db models can query safely in tests
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse
|
||||
for worker in $(seq 0 $((PARALLEL_WORKERS - 1))); do
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime_${worker}
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log_${worker}
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse_${worker}
|
||||
done
|
||||
bin/rails test
|
||||
|
||||
- name: Ensure Swagger docs are up to date
|
||||
|
|
|
|||
|
|
@ -147,6 +147,101 @@ select {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes bits-modal-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bits-modal-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bits-modal-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.94);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bits-modal-content-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dashboard-select-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dashboard-select-content-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.bits-modal-overlay[data-state="open"] {
|
||||
animation: bits-modal-overlay-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.bits-modal-overlay[data-state="closed"] {
|
||||
animation: bits-modal-overlay-out 180ms cubic-bezier(0.22, 1, 0.5, 1) both;
|
||||
}
|
||||
|
||||
.bits-modal-content {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.bits-modal-content[data-state="open"] {
|
||||
animation: bits-modal-content-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.bits-modal-content[data-state="closed"] {
|
||||
animation: bits-modal-content-out 200ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
.dashboard-select-popover {
|
||||
transform-origin: top left;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.dashboard-select-popover[data-state="open"] {
|
||||
animation: dashboard-select-content-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.dashboard-select-popover[data-state="closed"] {
|
||||
animation: dashboard-select-content-out 130ms cubic-bezier(0.32, 0, 0.67, 0) both;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class InertiaController < ApplicationController
|
|||
links << inertia_link("Leaderboards", leaderboards_path, active: helpers.current_page?(leaderboards_path))
|
||||
|
||||
if current_user
|
||||
links << inertia_link("Projects", my_projects_path, active: helpers.current_page?(my_projects_path))
|
||||
links << inertia_link("Projects", my_projects_path, active: request.path.start_with?("/my/projects"), inertia: true)
|
||||
links << inertia_link("Docs", docs_path, active: helpers.current_page?(docs_path) || request.path.start_with?("/docs"), inertia: true)
|
||||
links << inertia_link("Extensions", extensions_path, active: helpers.current_page?(extensions_path), inertia: true)
|
||||
links << inertia_link("Settings", my_settings_path, active: request.path.start_with?("/my/settings"), inertia: true)
|
||||
|
|
|
|||
55
app/controllers/my/heartbeat_imports_controller.rb
Normal file
55
app/controllers/my/heartbeat_imports_controller.rb
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
class My::HeartbeatImportsController < ApplicationController
|
||||
before_action :ensure_current_user
|
||||
before_action :ensure_development
|
||||
|
||||
def create
|
||||
unless params[:heartbeat_file].present?
|
||||
render json: { error: "pls select a file to import" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
file = params[:heartbeat_file]
|
||||
unless valid_json_file?(file)
|
||||
render json: { error: "pls upload only json (download from the button above it)" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
import_id = HeartbeatImportRunner.start(user: current_user, uploaded_file: file)
|
||||
status = HeartbeatImportRunner.status(user: current_user, import_id: import_id)
|
||||
|
||||
render json: {
|
||||
import_id: import_id,
|
||||
status: status
|
||||
}, status: :accepted
|
||||
rescue => e
|
||||
Rails.logger.error("Error starting heartbeat import for user #{current_user&.id}: #{e.message}")
|
||||
render json: { error: "error reading file: #{e.message}" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def show
|
||||
status = HeartbeatImportRunner.status(user: current_user, import_id: params[:id])
|
||||
if status.present?
|
||||
render json: status
|
||||
else
|
||||
render json: { error: "Import not found" }, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_json_file?(file)
|
||||
file.content_type == "application/json" || file.original_filename.to_s.ends_with?(".json")
|
||||
end
|
||||
|
||||
def ensure_current_user
|
||||
return if current_user
|
||||
|
||||
render json: { error: "You must be logged in to view this page." }, status: :unauthorized
|
||||
end
|
||||
|
||||
def ensure_development
|
||||
return if Rails.env.development?
|
||||
|
||||
render json: { error: "Heartbeat import is only available in development." }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
|
@ -1,17 +1,29 @@
|
|||
class My::ProjectRepoMappingsController < ApplicationController
|
||||
class My::ProjectRepoMappingsController < InertiaController
|
||||
layout "inertia", only: [ :index ]
|
||||
|
||||
before_action :ensure_current_user
|
||||
before_action :require_github_oauth, only: [ :edit, :update ]
|
||||
before_action :set_project_repo_mapping_for_edit, only: [ :edit, :update ]
|
||||
before_action :set_project_repo_mapping, only: [ :archive, :unarchive ]
|
||||
|
||||
def index
|
||||
@project_repo_mappings = current_user.project_repo_mappings.active
|
||||
@interval = params[:interval] || "daily"
|
||||
@from = params[:from]
|
||||
@to = params[:to]
|
||||
archived = show_archived?
|
||||
|
||||
archived = params[:show_archived] == "true"
|
||||
@project_count = project_count(archived)
|
||||
render inertia: "Projects/Index", props: {
|
||||
page_title: "My Projects",
|
||||
index_path: my_projects_path,
|
||||
show_archived: archived,
|
||||
archived_count: current_user.project_repo_mappings.archived.count,
|
||||
github_connected: current_user.github_uid.present?,
|
||||
github_auth_path: github_auth_path,
|
||||
settings_path: my_settings_path(anchor: "user_github_account"),
|
||||
interval: selected_interval,
|
||||
from: params[:from],
|
||||
to: params[:to],
|
||||
interval_label: helpers.human_interval_name(selected_interval, from: params[:from], to: params[:to]),
|
||||
total_projects: project_count(archived),
|
||||
projects_data: InertiaRails.defer { projects_payload(archived: archived) }
|
||||
}
|
||||
end
|
||||
|
||||
def edit
|
||||
|
|
@ -73,9 +85,115 @@ class My::ProjectRepoMappingsController < ApplicationController
|
|||
params.require(:project_repo_mapping).permit(:repo_url)
|
||||
end
|
||||
|
||||
def show_archived?
|
||||
params[:show_archived] == "true"
|
||||
end
|
||||
|
||||
def selected_interval
|
||||
params[:interval]
|
||||
end
|
||||
|
||||
def project_durations_cache_key
|
||||
key = "user_#{current_user.id}_project_durations_#{selected_interval}_v3"
|
||||
if selected_interval == "custom"
|
||||
sanitized_from = sanitized_cache_date(params[:from]) || "none"
|
||||
sanitized_to = sanitized_cache_date(params[:to]) || "none"
|
||||
key += "_#{sanitized_from}_#{sanitized_to}"
|
||||
end
|
||||
key
|
||||
end
|
||||
|
||||
def sanitized_cache_date(value)
|
||||
value.to_s.gsub(/[^0-9-]/, "")[0, 10].presence
|
||||
end
|
||||
|
||||
def projects_payload(archived:)
|
||||
mappings = current_user.project_repo_mappings.includes(:repository)
|
||||
scoped_mappings = archived ? mappings.archived : mappings.active
|
||||
mappings_by_name = scoped_mappings.index_by(&:project_name)
|
||||
archived_names = current_user.project_repo_mappings.archived.pluck(:project_name).index_with(true)
|
||||
labels_by_project_key = current_user.project_labels.pluck(:project_key, :label).to_h
|
||||
|
||||
cached = Rails.cache.fetch(project_durations_cache_key, expires_in: 1.minute) do
|
||||
hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to])
|
||||
{
|
||||
durations: hb.group(:project).duration_seconds,
|
||||
total_time: hb.duration_seconds
|
||||
}
|
||||
end
|
||||
|
||||
projects = cached[:durations].filter_map do |project_key, duration|
|
||||
next if duration <= 0
|
||||
next if archived_names.key?(project_key) != archived
|
||||
|
||||
mapping = mappings_by_name[project_key]
|
||||
display_name = labels_by_project_key[project_key].presence || project_key.presence || "Unknown"
|
||||
|
||||
{
|
||||
id: project_card_id(project_key),
|
||||
name: display_name,
|
||||
project_key: project_key,
|
||||
duration_seconds: duration,
|
||||
duration_label: format_duration(duration),
|
||||
duration_percent: 0,
|
||||
repo_url: mapping&.repo_url,
|
||||
repository: repository_payload(mapping&.repository),
|
||||
broken_name: broken_project_name?(project_key, display_name),
|
||||
manage_enabled: current_user.github_uid.present? && project_key.present?,
|
||||
edit_path: project_key.present? ? edit_my_project_repo_mapping_path(CGI.escape(project_key)) : nil,
|
||||
update_path: project_key.present? ? my_project_repo_mapping_path(CGI.escape(project_key)) : nil,
|
||||
archive_path: project_key.present? ? archive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil,
|
||||
unarchive_path: project_key.present? ? unarchive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil
|
||||
}
|
||||
end.sort_by { |project| -project[:duration_seconds] }
|
||||
|
||||
max_duration = projects.map { |project| project[:duration_seconds].to_f }.max || 1.0
|
||||
|
||||
projects.each do |project|
|
||||
project[:duration_percent] = ((project[:duration_seconds].to_f / max_duration) * 100).round(1)
|
||||
end
|
||||
|
||||
total_time = cached[:total_time].to_i
|
||||
|
||||
{
|
||||
total_time_seconds: total_time,
|
||||
total_time_label: format_duration(total_time),
|
||||
has_activity: total_time.positive?,
|
||||
projects: projects
|
||||
}
|
||||
end
|
||||
|
||||
def format_duration(seconds)
|
||||
helpers.short_time_detailed(seconds).presence || "0m"
|
||||
end
|
||||
|
||||
def project_card_id(project_key)
|
||||
raw_key = project_key.nil? ? "__nil__" : "str:#{project_key}"
|
||||
"project-#{raw_key.unpack1('H*')}"
|
||||
end
|
||||
|
||||
def broken_project_name?(project_key, display_name)
|
||||
key = project_key.to_s
|
||||
name = display_name.to_s
|
||||
|
||||
key.blank? || name.downcase == "unknown" || key.match?(/<<.*>>/) || name.match?(/<<.*>>/)
|
||||
end
|
||||
|
||||
def repository_payload(repository)
|
||||
return nil unless repository
|
||||
|
||||
{
|
||||
homepage: repository.homepage,
|
||||
stars: repository.stars,
|
||||
description: repository.description,
|
||||
formatted_languages: repository.formatted_languages,
|
||||
last_commit_ago: repository.last_commit_at ? "#{helpers.time_ago_in_words(repository.last_commit_at)} ago" : nil
|
||||
}
|
||||
end
|
||||
|
||||
def project_count(archived)
|
||||
archived_names = current_user.project_repo_mappings.archived.pluck(:project_name)
|
||||
hb = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to])
|
||||
hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to])
|
||||
projects = hb.select(:project).distinct.pluck(:project)
|
||||
projects.count { |proj| archived_names.include?(proj) == archived }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ class Settings::BaseController < InertiaController
|
|||
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)
|
||||
heartbeat_import_id = nil
|
||||
heartbeat_import_status = nil
|
||||
|
||||
if active_section.to_s == "data" && Rails.env.development?
|
||||
heartbeat_import_id = params[:heartbeat_import_id].presence
|
||||
heartbeat_import_status = HeartbeatImportRunner.status(user: @user, import_id: heartbeat_import_id) if heartbeat_import_id
|
||||
end
|
||||
|
||||
{
|
||||
active_section: active_section,
|
||||
|
|
@ -97,7 +104,7 @@ class Settings::BaseController < InertiaController
|
|||
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_heartbeat_import_path: my_heartbeat_imports_path,
|
||||
create_deletion_path: create_deletion_path,
|
||||
user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user)
|
||||
},
|
||||
|
|
@ -187,6 +194,10 @@ class Settings::BaseController < InertiaController
|
|||
ui: {
|
||||
show_dev_import: Rails.env.development?
|
||||
},
|
||||
heartbeat_import: {
|
||||
import_id: heartbeat_import_id,
|
||||
status: heartbeat_import_status
|
||||
},
|
||||
errors: {
|
||||
full_messages: @user.errors.full_messages,
|
||||
username: @user.errors[:username]
|
||||
|
|
|
|||
|
|
@ -210,8 +210,13 @@ module ApplicationHelper
|
|||
def modal_open_button(modal_id, text, **options)
|
||||
button_tag text, {
|
||||
type: "button",
|
||||
data: { action: "click->modal#open" },
|
||||
onclick: "document.getElementById('#{modal_id}').querySelector('[data-controller=\"modal\"]').dispatchEvent(new CustomEvent('modal:open', { bubbles: true }))"
|
||||
onclick: "document.getElementById('#{modal_id}')?.dispatchEvent(new CustomEvent('modal:open'))"
|
||||
}.merge(options)
|
||||
end
|
||||
|
||||
def safe_asset_path(asset_name, fallback: nil)
|
||||
asset_path(asset_name)
|
||||
rescue StandardError
|
||||
fallback.present? ? asset_path(fallback) : asset_name
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { Link } from "@inertiajs/svelte";
|
||||
import { Button as BitsButton } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type ButtonType = "button" | "submit" | "reset";
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
type = "button",
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
native = false,
|
||||
unstyled = false,
|
||||
children,
|
||||
class: className = "",
|
||||
|
|
@ -20,6 +22,7 @@
|
|||
type?: ButtonType;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
native?: boolean;
|
||||
unstyled?: boolean;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
|
|
@ -58,11 +61,17 @@
|
|||
</script>
|
||||
|
||||
{#if href}
|
||||
<Link {href} class={classes} {...rest}>
|
||||
{@render children?.()}
|
||||
</Link>
|
||||
{#if native}
|
||||
<BitsButton.Root {href} class={classes} {...rest}>
|
||||
{@render children?.()}
|
||||
</BitsButton.Root>
|
||||
{:else}
|
||||
<Link {href} class={classes} {...rest}>
|
||||
{@render children?.()}
|
||||
</Link>
|
||||
{/if}
|
||||
{:else}
|
||||
<button {type} class={classes} {...rest}>
|
||||
<BitsButton.Root {type} class={classes} {...rest}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
</BitsButton.Root>
|
||||
{/if}
|
||||
|
|
|
|||
105
app/javascript/components/Modal.svelte
Normal file
105
app/javascript/components/Modal.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
description = "",
|
||||
maxWidth = "max-w-lg",
|
||||
bodyClass = "mb-6 rounded-xl border border-surface-200/60 bg-darker/30 p-4",
|
||||
onContentClick,
|
||||
hasIcon = false,
|
||||
hasBody = false,
|
||||
hasActions = false,
|
||||
icon,
|
||||
body,
|
||||
actions,
|
||||
}: {
|
||||
open?: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
maxWidth?: string;
|
||||
bodyClass?: string;
|
||||
onContentClick?: (event: MouseEvent) => void;
|
||||
hasIcon?: boolean;
|
||||
hasBody?: boolean;
|
||||
hasActions?: boolean;
|
||||
icon?: Snippet;
|
||||
body?: Snippet;
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="bits-modal-overlay fixed inset-0 z-9999 bg-darker/80 backdrop-blur-md"
|
||||
/>
|
||||
|
||||
<Dialog.Content
|
||||
class={`bits-modal-content fixed inset-0 z-10000 m-auto h-fit w-[calc(100vw-2rem)] ${maxWidth} overflow-hidden rounded-2xl border border-surface-300/70 bg-surface shadow-[0_28px_90px_rgba(0,0,0,0.5)] outline-none`}
|
||||
onclick={onContentClick}
|
||||
>
|
||||
<div class="absolute inset-x-0 top-0 h-1 bg-primary"></div>
|
||||
|
||||
<div class="p-6 sm:p-8">
|
||||
<div class="mb-5 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
{#if hasIcon}
|
||||
<div
|
||||
class="mb-3 inline-flex items-center justify-center rounded-xl border border-surface-200/70 bg-surface-100/50 p-2.5 text-primary"
|
||||
>
|
||||
{@render icon?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Title
|
||||
class="text-balance text-2xl font-semibold tracking-tight text-surface-content"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
|
||||
{#if description}
|
||||
<Dialog.Description
|
||||
class="mt-2 text-sm leading-relaxed text-muted sm:text-[15px]"
|
||||
>
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Close
|
||||
class="inline-flex h-12 w-12 items-center justify-center rounded-lg text-surface-content/75 outline-none transition-colors hover:bg-surface-100/60 hover:text-surface-content focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6L6 18" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{#if hasBody}
|
||||
<div class={bodyClass}>
|
||||
{@render body?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasActions}
|
||||
<div>{@render actions?.()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
77
app/javascript/components/RailsModal.svelte
Normal file
77
app/javascript/components/RailsModal.svelte
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
// `Modal` is the shared visual shell for every modal in the app.
|
||||
// `RailsModal` stays as a thin adapter for server-rendered Rails views:
|
||||
// - pulls HTML fragments from the host element dataset/templates
|
||||
// - listens for legacy `modal:open` / `modal:close` DOM events
|
||||
// - closes when Rails action markup marks elements with `data-modal-close='true'`
|
||||
|
||||
let {
|
||||
modalId,
|
||||
title,
|
||||
description = "",
|
||||
iconHtml = "",
|
||||
customHtml = "",
|
||||
actionsHtml = "",
|
||||
maxWidth = "max-w-md",
|
||||
}: {
|
||||
modalId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
iconHtml?: string;
|
||||
customHtml?: string;
|
||||
actionsHtml?: string;
|
||||
maxWidth?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
const handleActionClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest("[data-modal-close='true']")) {
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const host = document.getElementById(modalId);
|
||||
if (!host) return;
|
||||
|
||||
const onOpen = () => (open = true);
|
||||
const onClose = () => (open = false);
|
||||
|
||||
host.addEventListener("modal:open", onOpen as EventListener);
|
||||
host.addEventListener("modal:close", onClose as EventListener);
|
||||
|
||||
return () => {
|
||||
host.removeEventListener("modal:open", onOpen as EventListener);
|
||||
host.removeEventListener("modal:close", onClose as EventListener);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
bind:open
|
||||
{title}
|
||||
{description}
|
||||
{maxWidth}
|
||||
onContentClick={handleActionClick}
|
||||
hasIcon={Boolean(iconHtml)}
|
||||
hasBody={Boolean(customHtml)}
|
||||
hasActions={Boolean(actionsHtml)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
{@html iconHtml}
|
||||
{/snippet}
|
||||
|
||||
{#snippet body()}
|
||||
{@html customHtml}
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
{@html actionsHtml}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
90
app/javascript/components/Select.svelte
Normal file
90
app/javascript/components/Select.svelte
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts">
|
||||
import { Select as BitsSelect } from "bits-ui";
|
||||
|
||||
type SelectItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
value = $bindable(""),
|
||||
items = [],
|
||||
placeholder = "Select an option",
|
||||
allowDeselect = false,
|
||||
disabled = false,
|
||||
class: className = "",
|
||||
}: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
items: SelectItem[];
|
||||
placeholder?: string;
|
||||
allowDeselect?: boolean;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const selectedLabel = $derived(
|
||||
items.find((item) => item.value === value)?.label ?? placeholder,
|
||||
);
|
||||
</script>
|
||||
|
||||
<BitsSelect.Root
|
||||
type="single"
|
||||
bind:value={value as never}
|
||||
{name}
|
||||
{allowDeselect}
|
||||
{disabled}
|
||||
{items}
|
||||
>
|
||||
<BitsSelect.Trigger
|
||||
{id}
|
||||
class={`inline-flex w-full items-center justify-between rounded-md border border-surface-200 bg-darker px-3 py-2 text-left text-sm text-surface-content transition-all duration-200 hover:border-surface-300 focus-visible:border-primary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 focus-visible:ring-offset-2 focus-visible:ring-offset-surface data-[placeholder]:text-surface-content/60 ${className}`}
|
||||
>
|
||||
<span class="truncate">{selectedLabel}</span>
|
||||
<svg
|
||||
class="ml-2 h-4 w-4 shrink-0 text-secondary/70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</BitsSelect.Trigger>
|
||||
|
||||
<BitsSelect.Portal>
|
||||
<BitsSelect.Content
|
||||
sideOffset={6}
|
||||
class="dashboard-select-popover z-1000 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-2 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
|
||||
>
|
||||
<BitsSelect.Viewport
|
||||
class="max-h-64 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1"
|
||||
>
|
||||
{#each items as item}
|
||||
<BitsSelect.Item
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
disabled={item.disabled}
|
||||
class="flex w-full select-none items-center justify-between rounded-md px-3 py-2 text-sm text-muted outline-none transition-all duration-150 hover:bg-surface-100/60 hover:text-surface-content data-[highlighted]:bg-surface-100/70 data-[highlighted]:text-surface-content data-[state=checked]:bg-primary/12 data-[state=checked]:text-surface-content data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
|
||||
>
|
||||
{#snippet children({ selected })}
|
||||
<span class="truncate">{item.label}</span>
|
||||
{#if selected}
|
||||
<span class="ml-2 text-primary">✓</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</BitsSelect.Item>
|
||||
{/each}
|
||||
</BitsSelect.Viewport>
|
||||
</BitsSelect.Content>
|
||||
</BitsSelect.Portal>
|
||||
</BitsSelect.Root>
|
||||
54
app/javascript/entrypoints/rails_modals.ts
Normal file
54
app/javascript/entrypoints/rails_modals.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { mount, unmount } from "svelte";
|
||||
import RailsModal from "../components/RailsModal.svelte";
|
||||
|
||||
type ModalComponent = ReturnType<typeof mount>;
|
||||
|
||||
const mountedModals = new Map<HTMLElement, ModalComponent>();
|
||||
|
||||
function templateHtml(host: HTMLElement, selector: string): string {
|
||||
const template = host.querySelector<HTMLTemplateElement>(selector);
|
||||
return template?.innerHTML.trim() ?? "";
|
||||
}
|
||||
|
||||
function mountModal(host: HTMLElement) {
|
||||
if (mountedModals.has(host)) return;
|
||||
if (!host.id) return;
|
||||
|
||||
const props = {
|
||||
modalId: host.id,
|
||||
title: host.dataset.modalTitle ?? "Confirm",
|
||||
description: host.dataset.modalDescription ?? "",
|
||||
maxWidth: host.dataset.modalMaxWidth ?? "max-w-md",
|
||||
iconHtml: templateHtml(host, "template[data-modal-icon]"),
|
||||
customHtml: templateHtml(host, "template[data-modal-custom]"),
|
||||
actionsHtml: templateHtml(host, "template[data-modal-actions]"),
|
||||
};
|
||||
|
||||
host.replaceChildren();
|
||||
const component = mount(RailsModal, { target: host, props });
|
||||
mountedModals.set(host, component);
|
||||
}
|
||||
|
||||
function pruneUnmounted() {
|
||||
for (const [host, component] of mountedModals) {
|
||||
if (!host.isConnected) {
|
||||
unmount(component);
|
||||
mountedModals.delete(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllRailsModals() {
|
||||
pruneUnmounted();
|
||||
document
|
||||
.querySelectorAll<HTMLElement>("[data-bits-modal]")
|
||||
.forEach((host) => mountModal(host));
|
||||
}
|
||||
|
||||
["DOMContentLoaded", "turbo:load", "turbo:render", "turbo:frame-load"].forEach(
|
||||
(eventName) => {
|
||||
document.addEventListener(eventName, mountAllRailsModals);
|
||||
},
|
||||
);
|
||||
|
||||
mountAllRailsModals();
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { Link, usePoll } from "@inertiajs/svelte";
|
||||
import Button from "../components/Button.svelte";
|
||||
import Modal from "../components/Modal.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import plur from "plur";
|
||||
|
||||
type NavLink = {
|
||||
|
|
@ -88,20 +89,17 @@
|
|||
let navOpen = $state(false);
|
||||
let logoutOpen = $state(false);
|
||||
let currentlyExpanded = $state(false);
|
||||
let flashVisible = $state(false);
|
||||
let flashVisible = $state(layout.nav.flash.length > 0);
|
||||
let flashHiding = $state(false);
|
||||
const flashHideDelay = 6000;
|
||||
const flashExitDuration = 250;
|
||||
const pollInterval = untrack(
|
||||
() => layout.currently_hacking?.interval || 30000,
|
||||
);
|
||||
|
||||
const toggleNav = () => (navOpen = !navOpen);
|
||||
const closeNav = () => (navOpen = false);
|
||||
const openLogout = () => (logoutOpen = true);
|
||||
const closeLogout = () => (logoutOpen = false);
|
||||
|
||||
usePoll(pollInterval, {
|
||||
usePoll(layout.currently_hacking?.interval || 30000, {
|
||||
only: ["currently_hacking"],
|
||||
});
|
||||
|
||||
|
|
@ -120,13 +118,6 @@
|
|||
}
|
||||
};
|
||||
|
||||
const handleModalBackdropKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
closeLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const countLabel = () =>
|
||||
`${layout.currently_hacking.count} ${plur("person", layout.currently_hacking.count)} currently hacking`;
|
||||
|
||||
|
|
@ -321,13 +312,7 @@
|
|||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class={`nav-overlay ${navOpen ? "open" : ""}`}
|
||||
aria-label="Close navigation"
|
||||
onclick={closeNav}
|
||||
/>
|
||||
<div class="nav-overlay" class:open={navOpen} onclick={closeNav}></div>
|
||||
|
||||
<aside
|
||||
class="flex flex-col min-h-screen w-52 bg-dark text-surface-content px-3 py-4 rounded-r-lg overflow-y-auto lg:block"
|
||||
|
|
@ -643,12 +628,8 @@
|
|||
<div
|
||||
class="fixed top-0 right-5 max-w-sm max-h-[80vh] bg-dark border border-darkless rounded-b-xl shadow-lg z-1000 overflow-hidden transform transition-transform duration-300 ease-out"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
<div
|
||||
class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between"
|
||||
aria-expanded={currentlyExpanded}
|
||||
aria-label="Toggle currently hacking users"
|
||||
onclick={toggleCurrentlyHacking}
|
||||
>
|
||||
<div class="text-surface-content text-sm font-medium">
|
||||
|
|
@ -657,7 +638,7 @@
|
|||
<span class="text-base">{countLabel()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if currentlyExpanded}
|
||||
{#if layout.currently_hacking.users.length === 0}
|
||||
|
|
@ -730,71 +711,54 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="fixed inset-0 flex items-center justify-center z-9999 transition-opacity duration-300 ease-in-out"
|
||||
class:opacity-0={!logoutOpen}
|
||||
class:pointer-events-none={!logoutOpen}
|
||||
style="background-color: rgba(0, 0, 0, 0.5);backdrop-filter: blur(4px);"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close logout dialog"
|
||||
onclick={(e) => e.target === e.currentTarget && closeLogout()}
|
||||
onkeydown={handleModalBackdropKeydown}
|
||||
<Modal
|
||||
bind:open={logoutOpen}
|
||||
title="Woah, hold on a sec!"
|
||||
description="You sure you want to log out? You can sign back in later but that is a bit of a hassle..."
|
||||
maxWidth="max-w-lg"
|
||||
hasIcon
|
||||
hasActions
|
||||
>
|
||||
<div
|
||||
class={`bg-dark border border-primary rounded-lg p-6 max-w-md w-full mx-4 flex flex-col items-center justify-center transform transition-transform duration-300 ease-in-out ${logoutOpen ? "scale-100" : "scale-95"}`}
|
||||
>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<div class="mb-4 flex justify-center w-full">
|
||||
<svg
|
||||
class="w-12 h-12 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{#snippet icon()}
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<h3
|
||||
class="text-2xl font-bold text-surface-content mb-2 text-center w-full"
|
||||
{#snippet actions()}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
onclick={closeLogout}
|
||||
variant="dark"
|
||||
class="h-10 w-full border border-surface-300 text-muted">Go back</Button
|
||||
>
|
||||
Woah hold on a sec
|
||||
</h3>
|
||||
<p class="text-muted mb-6 text-center w-full">
|
||||
You sure you want to log out? You can sign back in later but that is a
|
||||
bit of a hassle...
|
||||
</p>
|
||||
|
||||
<div class="flex w-full gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
onclick={closeLogout}
|
||||
variant="dark"
|
||||
class="w-full h-10 text-muted m-0">Go back</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<form method="post" action={layout.signout_path} class="m-0">
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
value={layout.csrf_token}
|
||||
/>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<Button type="submit" variant="primary" class="w-full h-10 m-0"
|
||||
>Log out now</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action={layout.signout_path} class="m-0">
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
value={layout.csrf_token}
|
||||
/>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="h-10 w-full text-on-primary">Log out now</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
:global(#app) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { Popover, RadioGroup } from "bits-ui";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
|
||||
const INTERVALS = [
|
||||
|
|
@ -33,7 +34,6 @@
|
|||
let open = $state(false);
|
||||
let customFrom = $state("");
|
||||
let customTo = $state("");
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
customFrom = from;
|
||||
|
|
@ -51,20 +51,9 @@
|
|||
});
|
||||
|
||||
const isDefault = $derived(!selected && !from && !to);
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (container && !container.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener("click", handleClickOutside, true);
|
||||
return () =>
|
||||
document.removeEventListener("click", handleClickOutside, true);
|
||||
}
|
||||
});
|
||||
const selectedIntervalValue = $derived(
|
||||
selected && !from && !to ? selected : "",
|
||||
);
|
||||
|
||||
function selectInterval(key: string) {
|
||||
onchange(key, "", "");
|
||||
|
|
@ -82,99 +71,112 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="filter relative" bind:this={container}>
|
||||
<div class="filter relative">
|
||||
<span
|
||||
class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider"
|
||||
>
|
||||
Date Range
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="group flex items-center border border-surface-200 rounded-lg bg-surface-100 m-0 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class="flex-1 px-3 py-2.5 text-sm cursor-pointer select-none text-surface-content m-0 bg-transparent flex items-center justify-between border-0"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span>{displayLabel}</span>
|
||||
<svg
|
||||
class="w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{#if !isDefault}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class="px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-surface-200 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150"
|
||||
onclick={clear}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<Popover.Root bind:open>
|
||||
<div
|
||||
class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-200 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2"
|
||||
class="group m-0 flex items-center rounded-lg border border-surface-200 bg-surface-100 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200 focus-within:border-primary/70 focus-within:ring-2 focus-within:ring-primary/35 focus-within:ring-offset-1 focus-within:ring-offset-surface"
|
||||
>
|
||||
<div class="overflow-y-auto m-0 max-h-56">
|
||||
{#each INTERVALS as interval}
|
||||
<label
|
||||
class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150"
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class="m-0 flex flex-1 cursor-pointer select-none items-center justify-between border-0 bg-transparent px-3 py-2.5 text-sm text-surface-content"
|
||||
{...props}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="interval"
|
||||
class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-surface-200 rounded-full bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-surface-300 transition-colors duration-150"
|
||||
checked={selected === interval.key && !from && !to}
|
||||
onchange={() => selectInterval(interval.key)}
|
||||
/>
|
||||
{interval.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="font-medium">{displayLabel}</span>
|
||||
<svg
|
||||
class={`h-4 w-4 text-secondary/60 transition-all duration-200 group-hover:text-secondary ${open ? "rotate-180 text-primary" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<hr class="my-2 border-surface-200" />
|
||||
|
||||
<div class="flex flex-col gap-2.5 pt-1">
|
||||
<label class="flex items-center justify-between text-sm text-muted">
|
||||
<span class="text-secondary/80">Start</span>
|
||||
<input
|
||||
type="date"
|
||||
class="ml-2 py-2 px-3 bg-dark border border-surface-200 rounded-md text-sm text-muted focus:outline-none focus:border-surface-300 transition-colors duration-150"
|
||||
bind:value={customFrom}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between text-sm text-muted">
|
||||
<span class="text-secondary/80">End</span>
|
||||
<input
|
||||
type="date"
|
||||
class="ml-2 py-2 px-3 bg-dark border border-surface-200 rounded-md text-sm text-muted focus:outline-none focus:border-surface-300 transition-colors duration-150"
|
||||
bind:value={customTo}
|
||||
/>
|
||||
</label>
|
||||
{#if !isDefault}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
class="mt-1 border-0 py-2.5"
|
||||
onclick={applyCustomRange}
|
||||
unstyled
|
||||
class="m-0 cursor-pointer border-0 border-l border-surface-200 bg-transparent px-2.5 py-2 text-sm leading-none text-secondary/60 transition-colors duration-150 hover:bg-red/10 hover:text-red"
|
||||
onclick={clear}
|
||||
>
|
||||
Apply
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
class="dashboard-select-popover z-1000 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-4 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
|
||||
>
|
||||
<div class="m-0 max-h-56 overflow-y-auto">
|
||||
<RadioGroup.Root
|
||||
value={selectedIntervalValue}
|
||||
onValueChange={selectInterval}
|
||||
class="flex flex-col gap-1 overflow-hidden"
|
||||
>
|
||||
{#each INTERVALS as interval}
|
||||
<RadioGroup.Item
|
||||
value={interval.key}
|
||||
class="flex w-full items-center rounded-md px-3 py-2 text-left text-sm text-muted outline-none transition-all duration-150 hover:bg-surface-100/60 hover:text-surface-content data-[highlighted]:bg-surface-100/70 data-[state=checked]:bg-primary/12 data-[state=checked]:text-surface-content"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span
|
||||
class={`mr-3 h-4 w-4 min-w-4 rounded-full border transition-colors ${checked ? "border-primary bg-primary shadow-[0_0_0_3px_rgba(0,0,0,0.2)]" : "border-surface-content/35 bg-surface/40"}`}
|
||||
></span>
|
||||
<span>{interval.label}</span>
|
||||
{/snippet}
|
||||
</RadioGroup.Item>
|
||||
{/each}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 border-t border-surface-content/15 pt-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex items-center justify-between text-sm text-muted">
|
||||
<span class="text-secondary/80">Start</span>
|
||||
<input
|
||||
type="date"
|
||||
class="ml-2 h-9 rounded-md border border-surface-content/20 bg-dark px-3 text-sm text-muted transition-colors duration-150 focus:border-primary/70 focus:outline-none focus:ring-2 focus:ring-primary/45 focus:ring-offset-1 focus:ring-offset-dark"
|
||||
bind:value={customFrom}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between text-sm text-muted">
|
||||
<span class="text-secondary/80">End</span>
|
||||
<input
|
||||
type="date"
|
||||
class="ml-2 h-9 rounded-md border border-surface-content/20 bg-dark px-3 text-sm text-muted transition-colors duration-150 focus:border-primary/70 focus:outline-none focus:ring-2 focus:ring-primary/45 focus:ring-offset-1 focus:ring-offset-dark"
|
||||
bind:value={customTo}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
class="mt-2 h-9 border-0"
|
||||
onclick={applyCustomRange}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { Checkbox, Popover } from "bits-ui";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
|
||||
let {
|
||||
|
|
@ -17,7 +18,6 @@
|
|||
|
||||
let open = $state(false);
|
||||
let search = $state("");
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
|
||||
let filtered = $derived(
|
||||
search
|
||||
|
|
@ -33,33 +33,11 @@
|
|||
: `${selected.length} selected`,
|
||||
);
|
||||
|
||||
function toggle(value: string) {
|
||||
if (selected.includes(value)) {
|
||||
onchange(selected.filter((s) => s !== value));
|
||||
} else {
|
||||
onchange([...selected, value]);
|
||||
}
|
||||
}
|
||||
|
||||
function clear(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
onchange([]);
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (container && !container.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener("click", handleClickOutside, true);
|
||||
return () =>
|
||||
document.removeEventListener("click", handleClickOutside, true);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
search = "";
|
||||
|
|
@ -67,86 +45,104 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="filter relative" bind:this={container}>
|
||||
<div class="filter relative">
|
||||
<span
|
||||
class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="group flex items-center border border-surface-200 rounded-lg bg-surface-100 m-0 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class="flex-1 px-3 py-2.5 text-sm cursor-pointer select-none text-surface-content m-0 bg-transparent flex items-center justify-between border-0 min-w-0"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span
|
||||
class="truncate {selected.length === 0
|
||||
? 'text-surface-content/60'
|
||||
: ''}"
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
<svg
|
||||
class={`w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary ${open ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{#if selected.length > 0}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class="px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-surface-200 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150"
|
||||
onclick={clear}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<Popover.Root bind:open>
|
||||
<div
|
||||
class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-200 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2"
|
||||
class="group m-0 flex items-center rounded-lg border border-surface-200 bg-surface-100 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200 focus-within:border-primary/70 focus-within:ring-2 focus-within:ring-primary/35 focus-within:ring-offset-1 focus-within:ring-offset-surface"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
class="w-full border border-surface-200 px-3 py-2.5 mb-2 bg-dark text-surface-content text-sm rounded-md h-auto placeholder:text-secondary/60 focus:outline-none focus:border-surface-300"
|
||||
bind:value={search}
|
||||
/>
|
||||
|
||||
<div class="overflow-y-auto m-0 max-h-64">
|
||||
{#each filtered as value}
|
||||
<label
|
||||
class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150"
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class="m-0 flex min-w-0 flex-1 cursor-pointer select-none items-center justify-between border-0 bg-transparent px-3 py-2.5 text-sm text-surface-content"
|
||||
{...props}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(value)}
|
||||
onchange={() => toggle(value)}
|
||||
class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-surface-200 rounded bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-surface-300 transition-colors duration-150"
|
||||
/>
|
||||
{value}
|
||||
</label>
|
||||
{/each}
|
||||
<span
|
||||
class="truncate font-medium {selected.length === 0
|
||||
? 'text-surface-content/60'
|
||||
: ''}"
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
<svg
|
||||
class={`h-4 w-4 text-secondary/60 transition-all duration-200 group-hover:text-secondary ${open ? "rotate-180 text-primary" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<div class="px-3 py-2.5 text-sm text-secondary/60">No results</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selected.length > 0}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class="m-0 cursor-pointer border-0 border-l border-surface-200 bg-transparent px-2.5 py-2 text-sm leading-none text-secondary/60 transition-colors duration-150 hover:bg-red/10 hover:text-red"
|
||||
onclick={clear}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
class="dashboard-select-popover z-1000 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-2 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
class="mb-2 h-10 w-full rounded-lg border border-surface-content/20 bg-dark px-3 text-sm text-surface-content placeholder:text-secondary/60 transition-colors duration-150 focus:border-primary/70 focus:outline-none focus:ring-2 focus:ring-primary/45 focus:ring-offset-1 focus:ring-offset-dark"
|
||||
bind:value={search}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="m-0 max-h-64 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1"
|
||||
>
|
||||
<Checkbox.Group
|
||||
value={selected}
|
||||
onValueChange={(next) => onchange(next as string[])}
|
||||
class="flex flex-col"
|
||||
>
|
||||
{#each filtered as value}
|
||||
<Checkbox.Root
|
||||
{value}
|
||||
class="flex w-full items-center rounded-md px-3 py-2 text-sm text-muted outline-none transition-all duration-150 hover:bg-surface-100/60 hover:text-surface-content data-[highlighted]:bg-surface-100/70 data-[state=checked]:bg-primary/12 data-[state=checked]:text-surface-content"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span
|
||||
class={`mr-3 inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border text-[10px] font-bold transition-all duration-150 ${checked ? "border-primary bg-primary text-on-primary" : "border-surface-content/35 bg-surface/40 text-transparent"}`}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span class="truncate">{value}</span>
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
{/each}
|
||||
</Checkbox.Group>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<div class="px-3 py-2 text-sm text-secondary/60">No results</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
|
|
|
|||
584
app/javascript/pages/Projects/Index.svelte
Normal file
584
app/javascript/pages/Projects/Index.svelte
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
<script lang="ts">
|
||||
import { Link } from "@inertiajs/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../components/Button.svelte";
|
||||
import Modal from "../../components/Modal.svelte";
|
||||
import IntervalSelect from "../Home/signedIn/IntervalSelect.svelte";
|
||||
|
||||
type RepositorySummary = {
|
||||
homepage?: string | null;
|
||||
stars?: number | null;
|
||||
description?: string | null;
|
||||
formatted_languages?: string | null;
|
||||
last_commit_ago?: string | null;
|
||||
};
|
||||
|
||||
type ProjectCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
project_key?: string | null;
|
||||
duration_seconds: number;
|
||||
duration_label: string;
|
||||
duration_percent: number;
|
||||
repo_url?: string | null;
|
||||
repository?: RepositorySummary | null;
|
||||
broken_name: boolean;
|
||||
manage_enabled: boolean;
|
||||
update_path?: string | null;
|
||||
archive_path?: string | null;
|
||||
unarchive_path?: string | null;
|
||||
};
|
||||
|
||||
type ProjectsData = {
|
||||
total_time_seconds: number;
|
||||
total_time_label: string;
|
||||
has_activity: boolean;
|
||||
projects: ProjectCard[];
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
page_title: string;
|
||||
index_path: string;
|
||||
show_archived: boolean;
|
||||
archived_count: number;
|
||||
github_connected: boolean;
|
||||
github_auth_path: string;
|
||||
settings_path: string;
|
||||
interval?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
interval_label: string;
|
||||
total_projects: number;
|
||||
projects_data?: ProjectsData;
|
||||
};
|
||||
|
||||
let {
|
||||
page_title,
|
||||
index_path,
|
||||
show_archived,
|
||||
archived_count,
|
||||
github_connected,
|
||||
github_auth_path,
|
||||
settings_path,
|
||||
interval = "",
|
||||
from = "",
|
||||
to = "",
|
||||
interval_label,
|
||||
total_projects,
|
||||
projects_data,
|
||||
}: PageProps = $props();
|
||||
|
||||
let csrfToken = $state("");
|
||||
let editingProjectKey = $state<string | null>(null);
|
||||
let repoUrlDraft = $state("");
|
||||
let statusChangeModalOpen = $state(false);
|
||||
let pendingStatusAction = $state<{
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
} | null>(null);
|
||||
|
||||
const skeletonCount = $derived.by(() => {
|
||||
const safeCount = Number.isFinite(total_projects) ? total_projects : 0;
|
||||
return Math.min(Math.max(safeCount, 4), 10);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
csrfToken =
|
||||
document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
?.getAttribute("content") || "";
|
||||
});
|
||||
|
||||
const buildProjectsPath = ({
|
||||
nextShowArchived = show_archived,
|
||||
nextInterval = interval || "",
|
||||
nextFrom = from || "",
|
||||
nextTo = to || "",
|
||||
}: {
|
||||
nextShowArchived?: boolean;
|
||||
nextInterval?: string;
|
||||
nextFrom?: string;
|
||||
nextTo?: string;
|
||||
} = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (nextShowArchived) query.set("show_archived", "true");
|
||||
if (nextInterval) query.set("interval", nextInterval);
|
||||
if (nextFrom) query.set("from", nextFrom);
|
||||
if (nextTo) query.set("to", nextTo);
|
||||
|
||||
const queryString = query.toString();
|
||||
return queryString ? `${index_path}?${queryString}` : index_path;
|
||||
};
|
||||
|
||||
const changeInterval = (
|
||||
nextInterval: string,
|
||||
nextFrom: string,
|
||||
nextTo: string,
|
||||
) => {
|
||||
const isCustom = Boolean(nextFrom || nextTo);
|
||||
window.location.href = buildProjectsPath({
|
||||
nextInterval: isCustom ? "custom" : nextInterval,
|
||||
nextFrom: isCustom ? nextFrom : "",
|
||||
nextTo: isCustom ? nextTo : "",
|
||||
});
|
||||
};
|
||||
|
||||
const openMappingEditor = (project: ProjectCard) => {
|
||||
editingProjectKey = project.project_key || null;
|
||||
repoUrlDraft = project.repo_url || "";
|
||||
};
|
||||
|
||||
const closeMappingEditor = () => {
|
||||
editingProjectKey = null;
|
||||
repoUrlDraft = "";
|
||||
};
|
||||
|
||||
const openStatusChangeModal = (project: ProjectCard, restoring: boolean) => {
|
||||
const path = restoring ? project.unarchive_path : project.archive_path;
|
||||
if (!path) return;
|
||||
|
||||
pendingStatusAction = {
|
||||
path,
|
||||
title: restoring
|
||||
? `Restore ${project.name}?`
|
||||
: `Archive ${project.name}?`,
|
||||
description: restoring
|
||||
? "This project will return to your active projects list and stats."
|
||||
: "This project will be hidden from most stats and listings, but it'll still be visible to you and any time logged will still count towards it. You can restore it anytime from the Archived Projects page.",
|
||||
confirmLabel: restoring ? "Restore project" : "Archive project",
|
||||
};
|
||||
statusChangeModalOpen = true;
|
||||
};
|
||||
|
||||
const closeStatusChangeModal = () => {
|
||||
statusChangeModalOpen = false;
|
||||
pendingStatusAction = null;
|
||||
};
|
||||
|
||||
const cardActionClass =
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-lg bg-surface-content/5 text-surface-content/70 transition-colors duration-200 hover:bg-surface-content/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{page_title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-3xl font-bold text-surface-content">My Projects</h1>
|
||||
{#if archived_count > 0}
|
||||
<div class="project-toggle-group">
|
||||
<Link
|
||||
href={buildProjectsPath({ nextShowArchived: false })}
|
||||
class={`project-toggle-btn ${!show_archived ? "active" : "inactive"}`}
|
||||
>
|
||||
Active
|
||||
</Link>
|
||||
<Link
|
||||
href={buildProjectsPath({ nextShowArchived: true })}
|
||||
class={`project-toggle-btn ${show_archived ? "active" : "inactive"}`}
|
||||
>
|
||||
Archived
|
||||
</Link>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !github_connected}
|
||||
<div class="mb-4 rounded-xl border border-yellow/30 bg-yellow/10 p-4">
|
||||
<p class="text-base font-medium text-yellow">
|
||||
Heads up! You can't link projects to GitHub until you connect your
|
||||
account.
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button href={github_auth_path} native class="w-full sm:w-fit">
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
<Button href={settings_path} variant="surface" class="w-full sm:w-fit">
|
||||
Open settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sm:max-w-3xs">
|
||||
<IntervalSelect
|
||||
from={from || ""}
|
||||
selected={interval || ""}
|
||||
to={to || ""}
|
||||
onchange={changeInterval}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if projects_data}
|
||||
<section class="mt-6">
|
||||
<p class="text-lg text-surface-content">
|
||||
{#if projects_data.has_activity}
|
||||
You've spent
|
||||
<span class="font-semibold text-primary"
|
||||
>{projects_data.total_time_label}</span
|
||||
>
|
||||
coding across {show_archived ? "archived" : "active"} projects.
|
||||
{:else}
|
||||
You haven't logged any time for this interval yet.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if projects_data.projects.length == 0}
|
||||
<div
|
||||
class="mt-4 rounded-xl border border-surface-200 bg-dark p-8 text-center"
|
||||
>
|
||||
<p class="text-muted">
|
||||
{show_archived
|
||||
? "No archived projects match this filter."
|
||||
: "No active projects match this filter."}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-6"
|
||||
>
|
||||
{#each projects_data.projects as project (project.id)}
|
||||
<article
|
||||
class="flex h-full flex-col gap-4 rounded-xl border border-primary bg-dark p-6 shadow-lg backdrop-blur-sm transition-all duration-300"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3
|
||||
class="truncate text-lg font-semibold text-surface-content"
|
||||
title={project.name}
|
||||
>
|
||||
{project.name}
|
||||
</h3>
|
||||
{#if project.repository?.stars}
|
||||
<p
|
||||
class="mt-2 inline-flex items-center gap-1 text-sm text-yellow"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 fill-current"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
{project.repository.stars}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#if project.repository?.homepage}
|
||||
<a
|
||||
href={project.repository.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View project website"
|
||||
class={cardActionClass}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if project.repo_url}
|
||||
<a
|
||||
href={project.repo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View repository"
|
||||
class={cardActionClass}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if project.manage_enabled}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class={cardActionClass}
|
||||
title={project.repo_url
|
||||
? "Edit mapping"
|
||||
: "Link repository"}
|
||||
onclick={() => openMappingEditor(project)}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16.862 3.487a2.1 2.1 0 0 1 2.97 2.97L9.75 16.54 6 17.25l.71-3.75z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.5 5.85l3.65 3.65"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
{#if show_archived && project.unarchive_path}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class={cardActionClass}
|
||||
title="Restore project"
|
||||
onclick={() => openStatusChangeModal(project, true)}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l-3-3 3-3"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 11h9a4 4 0 0 1 0 8h-2"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{:else if !show_archived && project.archive_path}
|
||||
<Button
|
||||
type="button"
|
||||
unstyled
|
||||
class={cardActionClass}
|
||||
title="Archive project"
|
||||
onclick={() => openStatusChangeModal(project, false)}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7h18l-2 11H5z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V4h8v3"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 11h4"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-2xl font-bold text-primary">
|
||||
{project.duration_label}
|
||||
</p>
|
||||
|
||||
{#if project.repository?.description}
|
||||
<p
|
||||
class="line-clamp-2 text-sm leading-relaxed text-surface-content/70"
|
||||
>
|
||||
{project.repository.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if project.repository?.formatted_languages}
|
||||
<p class="flex items-center gap-1 text-sm">
|
||||
<svg
|
||||
class="h-4 w-4 text-surface-content/50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.59 3.41L7 4.82L3.82 8L7 11.18L5.59 12.6L1 8zm5.82 0L16 8l-4.59 4.6L10 11.18L13.18 8L10 4.82zM22 6v12c0 1.11-.89 2-2 2H4a2 2 0 0 1-2-2v-4h2v4h16V6h-2.97V4H20c1.11 0 2 .89 2 2"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate text-surface-content/50"
|
||||
>{project.repository.formatted_languages}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if project.repository?.last_commit_ago}
|
||||
<p class="flex items-center gap-1 text-sm">
|
||||
<svg
|
||||
class="h-4 w-4 text-surface-content/50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 20c4.4 0 8-3.6 8-8s-3.6-8-8-8s-8 3.6-8 8s3.6 8 8 8m0-18c5.5 0 10 4.5 10 10s-4.5 10-10 10S2 17.5 2 12S6.5 2 12 2m.5 10.8l-4.8 2.8l-.7-1.4l4-2.3V7h1.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-surface-content/50"
|
||||
>Last commit {project.repository.last_commit_ago}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if project.broken_name}
|
||||
<div
|
||||
class="rounded-lg border border-yellow/30 bg-yellow/10 p-3"
|
||||
>
|
||||
<p class="text-sm leading-relaxed text-yellow/80">
|
||||
Your editor may be sending invalid project names. Time is
|
||||
shown here but can't be submitted to Hack Club programs.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.manage_enabled && editingProjectKey === project.project_key && project.update_path}
|
||||
<div class="mt-1 border-t border-surface-200/40 pt-4">
|
||||
<form
|
||||
method="post"
|
||||
action={project.update_path}
|
||||
class="space-y-3"
|
||||
>
|
||||
<input type="hidden" name="_method" value="patch" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
value={csrfToken}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
name="project_repo_mapping[repo_url]"
|
||||
bind:value={repoUrlDraft}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
class="w-full rounded-lg border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="flex-1">Save</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
onclick={closeMappingEditor}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="mt-6 animate-pulse">
|
||||
<div class="h-7 w-80 rounded bg-darkless"></div>
|
||||
<div
|
||||
class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-6"
|
||||
>
|
||||
{#each Array.from({ length: skeletonCount }) as _unused, index (index)}
|
||||
<div class="rounded-xl border border-primary bg-dark p-6">
|
||||
<div class="h-6 w-28 rounded bg-darkless"></div>
|
||||
<div class="mt-3 h-7 w-20 rounded bg-darkless"></div>
|
||||
<div class="mt-4 h-4 w-full rounded bg-darkless"></div>
|
||||
<div class="mt-2 h-4 w-3/4 rounded bg-darkless"></div>
|
||||
<div class="mt-4 h-8 w-full rounded bg-darkless"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
bind:open={statusChangeModalOpen}
|
||||
title={pendingStatusAction
|
||||
? pendingStatusAction.title
|
||||
: "Confirm project change"}
|
||||
description={pendingStatusAction
|
||||
? pendingStatusAction.description
|
||||
: "Confirm this project status change."}
|
||||
maxWidth="max-w-md"
|
||||
hasActions
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if pendingStatusAction}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
class="h-10 w-full border border-surface-300 text-muted"
|
||||
onclick={closeStatusChangeModal}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<form method="post" action={pendingStatusAction.path} class="m-0">
|
||||
<input type="hidden" name="_method" value="patch" />
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="h-10 w-full text-on-primary"
|
||||
>
|
||||
{pendingStatusAction.confirmLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import Modal from "../../../components/Modal.svelte";
|
||||
import Select from "../../../components/Select.svelte";
|
||||
import SettingsShell from "./Shell.svelte";
|
||||
import type { AccessPageProps } from "./types";
|
||||
|
||||
|
|
@ -24,15 +26,11 @@
|
|||
let rotatedApiKey = $state("");
|
||||
let rotatedApiKeyError = $state("");
|
||||
let apiKeyCopied = $state(false);
|
||||
let rotateApiKeyModalOpen = $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 = "";
|
||||
|
|
@ -64,6 +62,16 @@
|
|||
}
|
||||
};
|
||||
|
||||
const openRotateApiKeyModal = () => {
|
||||
if (rotatingApiKey) return;
|
||||
rotateApiKeyModalOpen = true;
|
||||
};
|
||||
|
||||
const confirmRotateApiKey = async () => {
|
||||
rotateApiKeyModalOpen = false;
|
||||
await rotateApiKey();
|
||||
};
|
||||
|
||||
const copyApiKey = async () => {
|
||||
if (!rotatedApiKey || typeof navigator === "undefined") return;
|
||||
await navigator.clipboard.writeText(rotatedApiKey);
|
||||
|
|
@ -118,16 +126,12 @@
|
|||
>
|
||||
Display style
|
||||
</label>
|
||||
<select
|
||||
<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>
|
||||
items={options.extension_text_types}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary">Save extension settings</Button>
|
||||
|
|
@ -142,7 +146,7 @@
|
|||
<Button
|
||||
type="button"
|
||||
class="mt-4"
|
||||
onclick={rotateApiKey}
|
||||
onclick={openRotateApiKeyModal}
|
||||
disabled={rotatingApiKey}
|
||||
>
|
||||
{rotatingApiKey ? "Rotating..." : "Rotate API key"}
|
||||
|
|
@ -200,3 +204,33 @@
|
|||
</section>
|
||||
</div>
|
||||
</SettingsShell>
|
||||
|
||||
<Modal
|
||||
bind:open={rotateApiKeyModalOpen}
|
||||
title="Rotate API key?"
|
||||
description="This immediately invalidates your current API key. Any integrations using the old key will stop until updated."
|
||||
maxWidth="max-w-md"
|
||||
hasActions
|
||||
>
|
||||
{#snippet actions()}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
class="h-10 w-full border border-surface-300 text-muted"
|
||||
onclick={() => (rotateApiKeyModalOpen = false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
class="h-10 w-full text-on-primary"
|
||||
onclick={confirmRotateApiKey}
|
||||
disabled={rotatingApiKey}
|
||||
>
|
||||
{rotatingApiKey ? "Rotating..." : "Rotate key"}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import Modal from "../../../components/Modal.svelte";
|
||||
import SettingsShell from "./Shell.svelte";
|
||||
import type { AdminPageProps } from "./types";
|
||||
|
||||
|
|
@ -16,6 +17,24 @@
|
|||
}: AdminPageProps = $props();
|
||||
|
||||
let csrfToken = $state("");
|
||||
let deleteMirrorModalOpen = $state(false);
|
||||
let selectedMirror = $state<{
|
||||
endpoint_url: string;
|
||||
destroy_path: string;
|
||||
} | null>(null);
|
||||
|
||||
const openDeleteMirrorModal = (mirror: {
|
||||
endpoint_url: string;
|
||||
destroy_path: string;
|
||||
}) => {
|
||||
selectedMirror = mirror;
|
||||
deleteMirrorModalOpen = true;
|
||||
};
|
||||
|
||||
const closeDeleteMirrorModal = () => {
|
||||
deleteMirrorModalOpen = false;
|
||||
selectedMirror = null;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
csrfToken =
|
||||
|
|
@ -54,26 +73,16 @@
|
|||
<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" variant="surface" size="xs">
|
||||
<div class="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="surface"
|
||||
size="xs"
|
||||
onclick={() => openDeleteMirrorModal(mirror)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -128,3 +137,39 @@
|
|||
</p>
|
||||
{/if}
|
||||
</SettingsShell>
|
||||
|
||||
<Modal
|
||||
bind:open={deleteMirrorModalOpen}
|
||||
title="Delete mirror endpoint?"
|
||||
description={selectedMirror
|
||||
? `${selectedMirror.endpoint_url} will stop receiving mirrored heartbeats.`
|
||||
: "This mirror endpoint will be removed."}
|
||||
maxWidth="max-w-lg"
|
||||
hasActions
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if selectedMirror}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
class="h-10 w-full border border-surface-300 text-muted"
|
||||
onclick={closeDeleteMirrorModal}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<form method="post" action={selectedMirror.destroy_path} class="m-0">
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="h-10 w-full text-on-primary"
|
||||
>
|
||||
Delete mirror
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Select from "../../../components/Select.svelte";
|
||||
import SettingsShell from "./Shell.svelte";
|
||||
import type { BadgesPageProps } from "./types";
|
||||
|
||||
|
|
@ -76,15 +77,14 @@
|
|||
>
|
||||
Theme
|
||||
</label>
|
||||
<select
|
||||
<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>
|
||||
items={options.badge_themes.map((theme) => ({
|
||||
value: theme,
|
||||
label: theme,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-surface-200 bg-darker p-4">
|
||||
|
|
@ -106,15 +106,14 @@
|
|||
>
|
||||
Project
|
||||
</label>
|
||||
<select
|
||||
<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>
|
||||
items={badges.projects.map((project) => ({
|
||||
value: project,
|
||||
label: project,
|
||||
}))}
|
||||
/>
|
||||
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4">
|
||||
<img
|
||||
src={projectBadgeUrl()}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { usePoll } from "@inertiajs/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { tweened } from "svelte/motion";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import SettingsShell from "./Shell.svelte";
|
||||
import type { DataPageProps } from "./types";
|
||||
|
||||
type ImportStatusPayload = NonNullable<
|
||||
DataPageProps["heartbeat_import"]["status"]
|
||||
>;
|
||||
type ImportCreateResponse = {
|
||||
import_id?: string;
|
||||
status?: ImportStatusPayload;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
active_section,
|
||||
section_paths,
|
||||
|
|
@ -15,18 +27,213 @@
|
|||
migration,
|
||||
data_export,
|
||||
ui,
|
||||
heartbeat_import,
|
||||
errors,
|
||||
admin_tools,
|
||||
}: DataPageProps = $props();
|
||||
|
||||
let csrfToken = $state("");
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let importId = $state("");
|
||||
let importState = $state("idle");
|
||||
let importMessage = $state("");
|
||||
let submitError = $state("");
|
||||
let processedCount = $state<number | null>(null);
|
||||
let totalCount = $state<number | null>(null);
|
||||
let importedCount = $state<number | null>(null);
|
||||
let skippedCount = $state<number | null>(null);
|
||||
let errorsCount = $state(0);
|
||||
let isStartingImport = $state(false);
|
||||
let isPolling = $state(false);
|
||||
const importPollParams: { heartbeat_import_id?: string } = {};
|
||||
const tweenedProgress = tweened(0, { duration: 320, easing: cubicOut });
|
||||
|
||||
const importInProgress = $derived(
|
||||
importState === "queued" ||
|
||||
importState === "counting" ||
|
||||
importState === "running",
|
||||
);
|
||||
|
||||
const { start: startStatusPolling, stop: stopStatusPolling } = usePoll(
|
||||
1000,
|
||||
{
|
||||
only: ["heartbeat_import"],
|
||||
data: importPollParams,
|
||||
preserveUrl: true,
|
||||
onHttpException: () => {
|
||||
if (importInProgress) {
|
||||
importMessage =
|
||||
"Connection issue while checking import status. Retrying...";
|
||||
}
|
||||
},
|
||||
onNetworkError: () => {
|
||||
if (importInProgress) {
|
||||
importMessage =
|
||||
"Connection issue while checking import status. Retrying...";
|
||||
}
|
||||
},
|
||||
},
|
||||
{ autoStart: false },
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
csrfToken =
|
||||
document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
?.getAttribute("content") || "";
|
||||
|
||||
syncImportFromProps(heartbeat_import);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
syncImportFromProps(heartbeat_import);
|
||||
});
|
||||
|
||||
function isTerminalImportState(state: string) {
|
||||
return state === "completed" || state === "failed";
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
stopStatusPolling();
|
||||
delete importPollParams.heartbeat_import_id;
|
||||
isPolling = false;
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (!importId) {
|
||||
return;
|
||||
}
|
||||
if (isPolling && importPollParams.heartbeat_import_id === importId) {
|
||||
return;
|
||||
}
|
||||
stopStatusPolling();
|
||||
importPollParams.heartbeat_import_id = importId;
|
||||
startStatusPolling();
|
||||
isPolling = true;
|
||||
}
|
||||
|
||||
function formatCount(value: number | null) {
|
||||
if (value === null || value === undefined) {
|
||||
return "—";
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function applyImportStatus(status: Partial<ImportStatusPayload>) {
|
||||
const state = status.state || "idle";
|
||||
const progress = Number(status.progress_percent ?? 0);
|
||||
const normalizedProgress = Number.isFinite(progress)
|
||||
? Math.min(Math.max(progress, 0), 100)
|
||||
: 0;
|
||||
|
||||
importState = state;
|
||||
importMessage = status.message || importMessage;
|
||||
processedCount = status.processed_count ?? processedCount;
|
||||
totalCount = status.total_count ?? totalCount;
|
||||
importedCount = status.imported_count ?? importedCount;
|
||||
skippedCount = status.skipped_count ?? skippedCount;
|
||||
errorsCount = status.errors_count ?? errorsCount;
|
||||
void tweenedProgress.set(normalizedProgress);
|
||||
}
|
||||
|
||||
function syncImportFromProps(
|
||||
serverImport: DataPageProps["heartbeat_import"],
|
||||
) {
|
||||
if (!serverImport) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverImport.import_id) {
|
||||
if (importId && serverImport.import_id !== importId) {
|
||||
return;
|
||||
}
|
||||
importId = serverImport.import_id;
|
||||
}
|
||||
|
||||
if (serverImport.import_id && !serverImport.status) {
|
||||
stopPolling();
|
||||
importState = "failed";
|
||||
importMessage = "Import status could not be found.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverImport.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyImportStatus(serverImport.status);
|
||||
if (isTerminalImportState(serverImport.status.state)) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (importId) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function resetImportState() {
|
||||
importState = "queued";
|
||||
importMessage = "Queued import.";
|
||||
submitError = "";
|
||||
processedCount = 0;
|
||||
totalCount = null;
|
||||
importedCount = null;
|
||||
skippedCount = null;
|
||||
errorsCount = 0;
|
||||
void tweenedProgress.set(0);
|
||||
}
|
||||
|
||||
async function startImport(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
submitError = "";
|
||||
|
||||
if (!selectedFile) {
|
||||
submitError = "Please choose a JSON file to import.";
|
||||
return;
|
||||
}
|
||||
|
||||
isStartingImport = true;
|
||||
resetImportState();
|
||||
stopPolling();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("heartbeat_file", selectedFile);
|
||||
|
||||
try {
|
||||
const response = await fetch(paths.create_heartbeat_import_path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrfToken,
|
||||
Accept: "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: formData,
|
||||
});
|
||||
const payload = (await response.json()) as ImportCreateResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "Unable to start import.");
|
||||
}
|
||||
|
||||
if (!payload.import_id) {
|
||||
throw new Error("Unable to start import.");
|
||||
}
|
||||
|
||||
importId = payload.import_id;
|
||||
if (payload.status) {
|
||||
applyImportStatus(payload.status);
|
||||
}
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
importState = "failed";
|
||||
importMessage = "Import failed to start.";
|
||||
submitError =
|
||||
error instanceof Error ? error.message : "Unable to start import.";
|
||||
} finally {
|
||||
isStartingImport = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsShell
|
||||
|
|
@ -135,12 +342,9 @@
|
|||
|
||||
{#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"
|
||||
onsubmit={startImport}
|
||||
>
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
<label
|
||||
class="mb-2 block text-sm text-surface-content"
|
||||
for="heartbeat_file"
|
||||
|
|
@ -150,14 +354,66 @@
|
|||
<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"
|
||||
disabled={importInProgress || isStartingImport}
|
||||
onchange={(event) => {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
selectedFile = target.files?.[0] ?? null;
|
||||
}}
|
||||
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<Button type="submit" variant="surface" class="mt-3 rounded-md">
|
||||
Import file
|
||||
|
||||
{#if submitError}
|
||||
<p class="mt-2 text-sm text-red-300">{submitError}</p>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="surface"
|
||||
class="mt-3 rounded-md"
|
||||
disabled={!selectedFile || importInProgress || isStartingImport}
|
||||
>
|
||||
{#if isStartingImport}
|
||||
Starting import...
|
||||
{:else if importInProgress}
|
||||
Importing...
|
||||
{:else}
|
||||
Import file
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if importState !== "idle"}
|
||||
<div
|
||||
class="mt-4 rounded-md border border-surface-200 bg-surface p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-surface-content">
|
||||
Status: {importState}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-primary">
|
||||
{Math.round($tweenedProgress)}%
|
||||
</p>
|
||||
</div>
|
||||
<progress
|
||||
max="100"
|
||||
value={$tweenedProgress}
|
||||
class="mt-2 h-2 w-full rounded-full bg-surface-200 accent-primary"
|
||||
></progress>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
{formatCount(processedCount)} / {formatCount(totalCount)} processed
|
||||
</p>
|
||||
{#if importMessage}
|
||||
<p class="mt-1 text-sm text-muted">{importMessage}</p>
|
||||
{/if}
|
||||
{#if importState === "completed"}
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Imported: {formatCount(importedCount)}. Skipped {formatCount(
|
||||
skippedCount,
|
||||
)} duplicates and {errorsCount.toLocaleString()} errors
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { Checkbox } from "bits-ui";
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import Modal from "../../../components/Modal.svelte";
|
||||
import SettingsShell from "./Shell.svelte";
|
||||
import type { IntegrationsPageProps } from "./types";
|
||||
|
||||
|
|
@ -21,6 +23,8 @@
|
|||
}: IntegrationsPageProps = $props();
|
||||
|
||||
let csrfToken = $state("");
|
||||
let usesSlackStatus = $state(user.uses_slack_status);
|
||||
let unlinkGithubModalOpen = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
csrfToken =
|
||||
|
|
@ -63,13 +67,16 @@
|
|||
|
||||
<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"
|
||||
<Checkbox.Root
|
||||
bind:checked={usesSlackStatus}
|
||||
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"
|
||||
/>
|
||||
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
Update my Slack status automatically
|
||||
</label>
|
||||
|
||||
|
|
@ -136,33 +143,19 @@
|
|||
>
|
||||
Reconnect GitHub
|
||||
</a>
|
||||
<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();
|
||||
}
|
||||
}}
|
||||
<Button
|
||||
type="button"
|
||||
variant="surface"
|
||||
class="rounded-md"
|
||||
onclick={() => (unlinkGithubModalOpen = true)}
|
||||
>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
<Button type="submit" variant="surface" class="rounded-md">
|
||||
Unlink GitHub
|
||||
</Button>
|
||||
</form>
|
||||
Unlink GitHub
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<Button href={paths.github_auth_path} native class="mt-4 rounded-md">
|
||||
Connect GitHub
|
||||
</a>
|
||||
</Button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
|
@ -232,3 +225,35 @@
|
|||
</section>
|
||||
</div>
|
||||
</SettingsShell>
|
||||
|
||||
<Modal
|
||||
bind:open={unlinkGithubModalOpen}
|
||||
title="Unlink GitHub account?"
|
||||
description="GitHub-based features will stop until you reconnect."
|
||||
maxWidth="max-w-md"
|
||||
hasActions
|
||||
>
|
||||
{#snippet actions()}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
class="h-10 w-full border border-surface-300 text-muted"
|
||||
onclick={() => (unlinkGithubModalOpen = false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<form method="post" action={paths.github_unlink_path} class="m-0">
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
class="h-10 w-full text-on-primary"
|
||||
>
|
||||
Unlink GitHub
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { Checkbox, RadioGroup } from "bits-ui";
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import Select from "../../../components/Select.svelte";
|
||||
import SettingsShell from "./Shell.svelte";
|
||||
import type { ProfilePageProps } from "./types";
|
||||
|
||||
|
|
@ -20,7 +22,8 @@
|
|||
}: ProfilePageProps = $props();
|
||||
|
||||
let csrfToken = $state("");
|
||||
let selectedTheme = $state("gruvbox_dark");
|
||||
let selectedTheme = $state(user.theme || "gruvbox_dark");
|
||||
let allowPublicStatsLookup = $state(user.allow_public_stats_lookup);
|
||||
|
||||
onMount(() => {
|
||||
csrfToken =
|
||||
|
|
@ -28,10 +31,6 @@
|
|||
.querySelector("meta[name='csrf-token']")
|
||||
?.getAttribute("content") || "";
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
selectedTheme = user.theme || "gruvbox_dark";
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsShell
|
||||
|
|
@ -63,33 +62,27 @@
|
|||
>
|
||||
Country
|
||||
</label>
|
||||
<select
|
||||
<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>
|
||||
items={[
|
||||
{ value: "", label: "Select a country" },
|
||||
...options.countries,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="user_timezone">
|
||||
<label for="timezone" class="mb-2 block text-sm text-surface-content">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
<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>
|
||||
items={options.timezones}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary">Save region settings</Button>
|
||||
|
|
@ -154,13 +147,16 @@
|
|||
name="user[allow_public_stats_lookup]"
|
||||
value="0"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox.Root
|
||||
bind:checked={allowPublicStatsLookup}
|
||||
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"
|
||||
/>
|
||||
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
Allow public stats lookup
|
||||
</label>
|
||||
|
||||
|
|
@ -177,82 +173,77 @@
|
|||
<input type="hidden" name="_method" value="patch" />
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<RadioGroup.Root
|
||||
name="user[theme]"
|
||||
bind:value={selectedTheme}
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
{#each options.themes as theme}
|
||||
{@const isSelected = selectedTheme === theme.value}
|
||||
<label
|
||||
class={`block cursor-pointer rounded-xl border p-4 transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-surface-100"
|
||||
: "border-surface-200 bg-darker/40 hover:border-surface-300"
|
||||
}`}
|
||||
<RadioGroup.Item
|
||||
value={theme.value}
|
||||
class="block cursor-pointer rounded-xl border p-4 text-left outline-none transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-surface-100 data-[state=unchecked]:border-surface-200 data-[state=unchecked]:bg-darker/40 data-[state=unchecked]:hover:border-surface-300"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="user[theme]"
|
||||
value={theme.value}
|
||||
checked={isSelected}
|
||||
class="sr-only"
|
||||
onchange={() => (selectedTheme = theme.value)}
|
||||
/>
|
||||
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-surface-content">
|
||||
{theme.label}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">{theme.description}</p>
|
||||
{#snippet children({ checked })}
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-surface-content">
|
||||
{theme.label}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">{theme.description}</p>
|
||||
</div>
|
||||
{#if checked}
|
||||
<span
|
||||
class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
Selected
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isSelected}
|
||||
<span
|
||||
class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
Selected
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-3 rounded-lg border p-2"
|
||||
style={`background:${theme.preview.darker};border-color:${theme.preview.darkless};color:${theme.preview.content};`}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md px-2 py-1"
|
||||
style={`background:${theme.preview.dark};`}
|
||||
class="mt-3 rounded-lg border p-2"
|
||||
style={`background:${theme.preview.darker};border-color:${theme.preview.darkless};color:${theme.preview.content};`}
|
||||
>
|
||||
<span class="text-[11px] font-semibold">Dashboard</span>
|
||||
<span class="text-[10px] opacity-80">2h 14m</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md px-2 py-1"
|
||||
style={`background:${theme.preview.dark};`}
|
||||
>
|
||||
<span class="text-[11px] font-semibold">Dashboard</span>
|
||||
<span class="text-[10px] opacity-80">2h 14m</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 grid grid-cols-[1fr_auto] items-center gap-2">
|
||||
<span
|
||||
class="h-2 rounded"
|
||||
style={`background:${theme.preview.primary};`}
|
||||
></span>
|
||||
<span
|
||||
class="h-2 w-8 rounded"
|
||||
style={`background:${theme.preview.darkless};`}
|
||||
></span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 grid grid-cols-[1fr_auto] items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 rounded"
|
||||
style={`background:${theme.preview.primary};`}
|
||||
></span>
|
||||
<span
|
||||
class="h-2 w-8 rounded"
|
||||
style={`background:${theme.preview.darkless};`}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex gap-1.5">
|
||||
<span
|
||||
class="h-1.5 w-6 rounded"
|
||||
style={`background:${theme.preview.info};`}
|
||||
></span>
|
||||
<span
|
||||
class="h-1.5 w-6 rounded"
|
||||
style={`background:${theme.preview.success};`}
|
||||
></span>
|
||||
<span
|
||||
class="h-1.5 w-6 rounded"
|
||||
style={`background:${theme.preview.warning};`}
|
||||
></span>
|
||||
<div class="mt-2 flex gap-1.5">
|
||||
<span
|
||||
class="h-1.5 w-6 rounded"
|
||||
style={`background:${theme.preview.info};`}
|
||||
></span>
|
||||
<span
|
||||
class="h-1.5 w-6 rounded"
|
||||
style={`background:${theme.preview.success};`}
|
||||
></span>
|
||||
<span
|
||||
class="h-1.5 w-6 rounded"
|
||||
style={`background:${theme.preview.warning};`}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{/snippet}
|
||||
</RadioGroup.Item>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<Button type="submit" variant="primary">Save theme</Button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export type PathsProps = {
|
|||
migrate_heartbeats_path: string;
|
||||
export_all_heartbeats_path: string;
|
||||
export_range_heartbeats_path: string;
|
||||
import_heartbeats_path: string;
|
||||
create_heartbeat_import_path: string;
|
||||
create_deletion_path: string;
|
||||
user_wakatime_mirrors_path: string;
|
||||
};
|
||||
|
|
@ -138,6 +138,26 @@ export type UiProps = {
|
|||
show_dev_import: boolean;
|
||||
};
|
||||
|
||||
export type HeartbeatImportStatusProps = {
|
||||
import_id: string;
|
||||
state: string;
|
||||
progress_percent: number;
|
||||
processed_count: number;
|
||||
total_count: number | null;
|
||||
imported_count: number | null;
|
||||
skipped_count: number | null;
|
||||
errors_count: number;
|
||||
message: string;
|
||||
updated_at: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
};
|
||||
|
||||
export type HeartbeatImportProps = {
|
||||
import_id?: string | null;
|
||||
status?: HeartbeatImportStatusProps | null;
|
||||
};
|
||||
|
||||
export type ErrorsProps = {
|
||||
full_messages: string[];
|
||||
username: string[];
|
||||
|
|
@ -189,6 +209,7 @@ export type DataPageProps = SettingsCommonProps & {
|
|||
migration: MigrationProps;
|
||||
data_export: DataExportProps;
|
||||
ui: UiProps;
|
||||
heartbeat_import: HeartbeatImportProps;
|
||||
};
|
||||
|
||||
export type AdminPageProps = SettingsCommonProps & {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { Checkbox } from "bits-ui";
|
||||
import Button from "../../components/Button.svelte";
|
||||
import Stepper from "./Stepper.svelte";
|
||||
|
||||
|
|
@ -47,11 +48,17 @@
|
|||
<label
|
||||
class="flex items-center gap-3 cursor-pointer select-none group"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox.Root
|
||||
bind:checked={agreed}
|
||||
class="w-5 h-5 rounded border-darkless bg-darker text-primary focus:ring-primary focus:ring-offset-darker transition-colors cursor-pointer"
|
||||
/>
|
||||
class="inline-flex h-5 w-5 min-w-5 items-center justify-center rounded border border-darkless bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span
|
||||
class={checked ? "text-xs font-bold leading-none" : "hidden"}
|
||||
>✓</span
|
||||
>
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
<span class="font-medium">I understand and agree to the rules</span>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
7
app/jobs/heartbeat_import_job.rb
Normal file
7
app/jobs/heartbeat_import_job.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class HeartbeatImportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(user_id, import_id, file_path)
|
||||
HeartbeatImportRunner.run_import(user_id: user_id, import_id: import_id, file_path: file_path)
|
||||
end
|
||||
end
|
||||
140
app/services/heartbeat_import_runner.rb
Normal file
140
app/services/heartbeat_import_runner.rb
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
require "fileutils"
|
||||
require "securerandom"
|
||||
|
||||
class HeartbeatImportRunner
|
||||
STATUS_TTL = 12.hours
|
||||
PROGRESS_INTERVAL = 250
|
||||
|
||||
def self.start(user:, uploaded_file:)
|
||||
import_id = SecureRandom.uuid
|
||||
file_path = persist_uploaded_file(uploaded_file, import_id)
|
||||
|
||||
write_status(user_id: user.id, import_id: import_id, attributes: {
|
||||
state: "queued",
|
||||
progress_percent: 0,
|
||||
processed_count: 0,
|
||||
total_count: nil,
|
||||
imported_count: nil,
|
||||
skipped_count: nil,
|
||||
errors_count: 0,
|
||||
message: "Queued import."
|
||||
})
|
||||
|
||||
HeartbeatImportJob.perform_later(user.id, import_id, file_path)
|
||||
|
||||
import_id
|
||||
end
|
||||
|
||||
def self.status(user:, import_id:)
|
||||
Rails.cache.read(cache_key(user.id, import_id))
|
||||
end
|
||||
|
||||
def self.run_import(user_id:, import_id:, file_path:)
|
||||
ActiveRecord::Base.connection_pool.with_connection do
|
||||
user = User.find_by(id: user_id)
|
||||
unless user
|
||||
write_status(user_id: user_id, import_id: import_id, attributes: {
|
||||
state: "failed",
|
||||
progress_percent: 0,
|
||||
message: "User not found.",
|
||||
finished_at: Time.current.iso8601
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
file_content = File.read(file_path).force_encoding("UTF-8")
|
||||
|
||||
write_status(user_id: user_id, import_id: import_id, attributes: {
|
||||
state: "running",
|
||||
total_count: nil,
|
||||
progress_percent: 0,
|
||||
processed_count: 0,
|
||||
started_at: Time.current.iso8601,
|
||||
message: "Importing heartbeats..."
|
||||
})
|
||||
|
||||
result = HeartbeatImportService.import_from_file(
|
||||
file_content,
|
||||
user,
|
||||
progress_interval: PROGRESS_INTERVAL,
|
||||
on_progress: lambda { |processed_count|
|
||||
write_status(user_id: user_id, import_id: import_id, attributes: {
|
||||
state: "running",
|
||||
progress_percent: 0,
|
||||
processed_count: processed_count,
|
||||
total_count: nil,
|
||||
message: "Importing heartbeats..."
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if result[:success]
|
||||
write_status(user_id: user_id, import_id: import_id, attributes: {
|
||||
state: "completed",
|
||||
progress_percent: 100,
|
||||
processed_count: result[:total_count],
|
||||
total_count: result[:total_count],
|
||||
imported_count: result[:imported_count],
|
||||
skipped_count: result[:skipped_count],
|
||||
errors_count: result[:errors].length,
|
||||
message: build_success_message(result),
|
||||
finished_at: Time.current.iso8601
|
||||
})
|
||||
else
|
||||
write_status(user_id: user_id, import_id: import_id, attributes: {
|
||||
state: "failed",
|
||||
progress_percent: 0,
|
||||
imported_count: result[:imported_count],
|
||||
skipped_count: result[:skipped_count],
|
||||
errors_count: result[:errors].length,
|
||||
message: "Import failed: #{result[:error]}",
|
||||
finished_at: Time.current.iso8601
|
||||
})
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
write_status(user_id: user_id, import_id: import_id, attributes: {
|
||||
state: "failed",
|
||||
message: "Import failed: #{e.message}",
|
||||
finished_at: Time.current.iso8601
|
||||
})
|
||||
ensure
|
||||
FileUtils.rm_f(file_path) if file_path.present?
|
||||
ActiveRecord::Base.clear_active_connections!
|
||||
end
|
||||
|
||||
def self.write_status(user_id:, import_id:, attributes:)
|
||||
key = cache_key(user_id, import_id)
|
||||
existing = Rails.cache.read(key) || {}
|
||||
payload = existing.merge(attributes).merge(
|
||||
import_id: import_id,
|
||||
updated_at: Time.current.iso8601
|
||||
)
|
||||
|
||||
Rails.cache.write(key, payload, expires_in: STATUS_TTL)
|
||||
payload
|
||||
end
|
||||
|
||||
def self.persist_uploaded_file(uploaded_file, import_id)
|
||||
tmp_dir = Rails.root.join("tmp", "heartbeat_imports")
|
||||
FileUtils.mkdir_p(tmp_dir)
|
||||
|
||||
ext = File.extname(uploaded_file.original_filename.to_s)
|
||||
ext = ".json" if ext.blank?
|
||||
file_path = tmp_dir.join("#{import_id}#{ext}")
|
||||
FileUtils.cp(uploaded_file.tempfile.path, file_path)
|
||||
|
||||
file_path.to_s
|
||||
end
|
||||
|
||||
def self.cache_key(user_id, import_id)
|
||||
"heartbeat_import_status:user:#{user_id}:import:#{import_id}"
|
||||
end
|
||||
|
||||
def self.build_success_message(result)
|
||||
message = "Imported #{result[:imported_count]} out of #{result[:total_count]} heartbeats in #{result[:time_taken]}s."
|
||||
return message if result[:skipped_count].zero?
|
||||
|
||||
"#{message} Skipped #{result[:skipped_count]} duplicate heartbeats."
|
||||
end
|
||||
end
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
class HeartbeatImportService
|
||||
BATCH_SIZE = 50_000
|
||||
|
||||
def self.import_from_file(file_content, user)
|
||||
def self.import_from_file(file_content, user, on_progress: nil, progress_interval: 250)
|
||||
unless Rails.env.development?
|
||||
raise StandardError, "Not dev env, not running"
|
||||
end
|
||||
|
|
@ -16,6 +16,7 @@ class HeartbeatImportService
|
|||
|
||||
handler = HeartbeatSaxHandler.new do |hb|
|
||||
total_count += 1
|
||||
on_progress&.call(total_count) if progress_interval.positive? && (total_count % progress_interval).zero?
|
||||
|
||||
begin
|
||||
time_value = hb["time"].is_a?(String) ? Time.parse(hb["time"]).to_f : hb["time"].to_f
|
||||
|
|
@ -62,6 +63,7 @@ class HeartbeatImportService
|
|||
end
|
||||
|
||||
Oj.saj_parse(handler, file_content)
|
||||
on_progress&.call(total_count)
|
||||
|
||||
if total_count.zero?
|
||||
raise StandardError, "Not correct format, download from /my/settings on the offical hackatime then import here"
|
||||
|
|
@ -90,6 +92,17 @@ class HeartbeatImportService
|
|||
}
|
||||
end
|
||||
|
||||
def self.count_heartbeats(file_content)
|
||||
total_count = 0
|
||||
|
||||
handler = HeartbeatSaxHandler.new do |_hb|
|
||||
total_count += 1
|
||||
end
|
||||
|
||||
Oj.saj_parse(handler, file_content)
|
||||
total_count
|
||||
end
|
||||
|
||||
def self.flush_batch(seen_hashes)
|
||||
return 0 if seen_hashes.empty?
|
||||
|
||||
|
|
@ -100,8 +113,10 @@ class HeartbeatImportService
|
|||
r[:updated_at] = timestamp
|
||||
end
|
||||
|
||||
result = Heartbeat.upsert_all(records, unique_by: [ :fields_hash ])
|
||||
result.length
|
||||
ActiveRecord::Base.logger.silence do
|
||||
result = Heartbeat.upsert_all(records, unique_by: [ :fields_hash ])
|
||||
result.length
|
||||
end
|
||||
end
|
||||
|
||||
class HeartbeatSaxHandler < Oj::Saj
|
||||
|
|
|
|||
|
|
@ -84,10 +84,14 @@ class ProjectStatsQuery
|
|||
end
|
||||
|
||||
def scoped_heartbeats(start_time, end_time)
|
||||
start_timestamp = timestamp_value(start_time)
|
||||
end_timestamp = timestamp_value(end_time)
|
||||
return @user.heartbeats.none if start_timestamp.nil? || end_timestamp.nil?
|
||||
|
||||
@user.heartbeats
|
||||
.with_valid_timestamps
|
||||
.where.not(project: [ nil, "" ])
|
||||
.where(time: start_time..end_time)
|
||||
.where(time: start_timestamp..end_timestamp)
|
||||
end
|
||||
|
||||
def archived_project_names
|
||||
|
|
@ -138,4 +142,15 @@ class ProjectStatsQuery
|
|||
|
||||
Time.at(time_value.to_f).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
end
|
||||
|
||||
def timestamp_value(value)
|
||||
case value
|
||||
when Numeric
|
||||
value.to_f
|
||||
when Time, ActiveSupport::TimeWithZone
|
||||
value.to_f
|
||||
when DateTime
|
||||
value.to_time.to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
<meta property="og:description" content="<%= @og_description || content_for(:og_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
|
||||
<meta property="og:url" content="<%= content_for(:og_url) || request.original_url %>">
|
||||
<meta property="og:type" content="<%= content_for(:og_type) || 'website' %>">
|
||||
<meta property="og:image" content="<%= content_for(:og_image) || asset_path('og.jpg') %>">
|
||||
<meta property="og:image" content="<%= content_for(:og_image) || safe_asset_path('og.jpg', fallback: 'favicon.png') %>">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Hackatime">
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
<meta name="twitter:creator" content="@hackclub">
|
||||
<meta name="twitter:title" content="<%= @twitter_title || content_for(:twitter_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">
|
||||
<meta name="twitter:description" content="<%= @twitter_description || content_for(:twitter_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
|
||||
<meta name="twitter:image" content="<%= content_for(:twitter_image) || asset_path('favicon.png') %>">
|
||||
<meta name="twitter:image" content="<%= content_for(:twitter_image) || safe_asset_path('favicon.png', fallback: 'og.jpg') %>">
|
||||
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
|
@ -184,15 +184,16 @@
|
|||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= stylesheet_link_tag :app %>
|
||||
<%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %>
|
||||
<%= vite_client_tag %>
|
||||
<%= vite_typescript_tag "rails_modals" %>
|
||||
<% if Sentry.get_trace_propagation_meta %>
|
||||
<%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %>
|
||||
<% end %>
|
||||
<%# Vite assets are only required for the Inertia layout. %>
|
||||
|
||||
<!-- Lets users record their screen from your site -->
|
||||
<meta name="jam:team" content="a5978e52-2479-4dd3-9883-593aa7a4f121">
|
||||
<script type="module" src="https://js.jam.dev/recorder.js"></script>
|
||||
|
||||
|
||||
<!-- Captures user events and developer logs -->
|
||||
<script type="module" src="https://js.jam.dev/capture.js"></script>
|
||||
|
||||
|
|
@ -249,6 +250,6 @@
|
|||
</div>
|
||||
<div data-currently-hacking-target="content" style="display: none;"></div>
|
||||
</div>
|
||||
<%= render 'shared/modal', modal_id: 'logout-modal', title: 'Woah hold on a sec', description: 'You sure you want to log out? You can sign back in later but that is a bit of a hassle...', icon_svg: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"/>', icon_color: 'text-primary', buttons: [{ text: 'Go back', class: 'bg-dark hover:bg-darkless border border-darkless text-muted', action: 'click->modal#close' }, { text: 'Log out now', class: 'bg-primary hover:bg-primary/75 text-on-primary font-medium', form: true, url: signout_path, method: 'delete' }] %>
|
||||
<%= render 'shared/modal', modal_id: 'logout-modal', title: 'Woah, hold on a sec!', description: 'You sure you want to log out? You can sign back in later but that is a bit of a hassle...', icon_svg: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"/>', icon_color: 'text-primary', buttons: [{ text: 'Go back', class: 'bg-dark hover:bg-darkless border border-darkless text-muted', action: 'click->modal#close' }, { text: 'Log out now', class: 'bg-primary hover:bg-primary/75 text-on-primary font-medium', form: true, url: signout_path, method: 'delete' }] %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
<meta property="og:description" content="<%= @og_description || content_for(:og_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
|
||||
<meta property="og:url" content="<%= content_for(:og_url) || request.original_url %>">
|
||||
<meta property="og:type" content="<%= content_for(:og_type) || 'website' %>">
|
||||
<meta property="og:image" content="<%= content_for(:og_image) || asset_path('og.jpg') %>">
|
||||
<meta property="og:image" content="<%= content_for(:og_image) || safe_asset_path('og.jpg', fallback: 'favicon.png') %>">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Hackatime">
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
<meta name="twitter:creator" content="@hackclub">
|
||||
<meta name="twitter:title" content="<%= @twitter_title || content_for(:twitter_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">
|
||||
<meta name="twitter:description" content="<%= @twitter_description || content_for(:twitter_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
|
||||
<meta name="twitter:image" content="<%= content_for(:twitter_image) || asset_path('favicon.png') %>">
|
||||
<meta name="twitter:image" content="<%= content_for(:twitter_image) || safe_asset_path('favicon.png', fallback: 'og.jpg') %>">
|
||||
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
|
|
|||
|
|
@ -7,60 +7,88 @@
|
|||
buttons ||= []
|
||||
max_width ||= 'max-w-md'
|
||||
custom ||= nil
|
||||
|
||||
custom_html =
|
||||
if custom.is_a?(String)
|
||||
sanitize(
|
||||
custom,
|
||||
tags: %w[div p span a button form input label select option textarea h1 h2 h3 h4 h5 h6 ul ol li strong em code pre hr br svg path circle rect line polyline polygon],
|
||||
attributes: %w[class id href type name value placeholder data-controller data-action data-target data-token for method required disabled readonly accept maxlength action style target rel]
|
||||
)
|
||||
elsif custom.present?
|
||||
custom
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
actions_html = capture do
|
||||
if buttons.any?
|
||||
%>
|
||||
|
||||
<div id="<%= modal_id %>" class="fixed inset-0 flex items-center justify-center z-9999 opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out hidden" style="background-color: rgba(0, 0, 0, 0.5);backdrop-filter: blur(4px);" data-controller="modal">
|
||||
<div class="bg-dark border border-primary rounded-lg p-6 <%= max_width %> w-full mx-4 flex flex-col items-center justify-center transform scale-95 transition-transform duration-300 ease-in-out" data-modal-target="content">
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<% if icon_svg %>
|
||||
<div class="mb-4 flex justify-center w-full">
|
||||
<svg class="w-12 h-12 <%= icon_color %>" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<%= sanitize icon_svg, tags: %w[path circle rect line polyline polygon], attributes: %w[d cx cy r x y x1 y1 x2 y2 points stroke-linecap stroke-linejoin stroke-width fill] %>
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<h3 class="text-2xl font-bold text-surface-content mb-2 text-center w-full"><%= title %></h3>
|
||||
|
||||
<% if description %>
|
||||
<p class="text-muted mb-6 text-center w-full"><%= description %></p>
|
||||
<% end %>
|
||||
|
||||
<% if custom.is_a?(String) %>
|
||||
<%= sanitize custom, tags: %w[div p span a button form input label select option textarea h1 h2 h3 h4 h5 h6 ul ol li strong em code pre], attributes: %w[class id href type name value placeholder data-controller data-action data-target data-token for method required disabled readonly accept maxlength action] %>
|
||||
<% elsif custom.present? %>
|
||||
<%= custom %>
|
||||
<% end %>
|
||||
|
||||
<% if buttons.any? %>
|
||||
<div class="flex w-full gap-3">
|
||||
<% buttons.each do |button| %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<% if button[:form] %>
|
||||
<% if button[:form_id] %>
|
||||
<button type="submit" form="<%= button[:form_id] %>" class="w-full h-10 px-4 rounded-lg transition-colors duration-200 font-medium cursor-pointer m-0 <%= button[:class] %>">
|
||||
<%= button[:text] %>
|
||||
</button>
|
||||
<% else %>
|
||||
<form method="post" action="<%= button[:url] %>" class="m-0">
|
||||
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
|
||||
<% if button[:method] && button[:method] != 'post' %>
|
||||
<input type="hidden" name="_method" value="<%= button[:method] %>">
|
||||
<% end %>
|
||||
<button type="submit" class="w-full h-10 px-4 rounded-lg transition-colors duration-200 font-medium cursor-pointer m-0 <%= button[:class] %>">
|
||||
<%= button[:text] %>
|
||||
</button>
|
||||
</form>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<% buttons.each do |button| %>
|
||||
<% button_classes = "inline-flex w-full items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors duration-150 #{button[:class]}" %>
|
||||
<% if button[:form] %>
|
||||
<% if button[:form_id] %>
|
||||
<button type="submit" form="<%= button[:form_id] %>" class="<%= button_classes %>">
|
||||
<%= button[:text] %>
|
||||
</button>
|
||||
<% else %>
|
||||
<form method="post" action="<%= button[:url] %>" class="m-0 w-full">
|
||||
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
|
||||
<% if button[:method] && button[:method] != 'post' %>
|
||||
<input type="hidden" name="_method" value="<%= button[:method] %>">
|
||||
<% end %>
|
||||
<% else %>
|
||||
<button type="button" data-action="<%= button[:action] || 'click->modal#close' %>" class="w-full h-10 px-4 rounded-lg transition-colors duration-200 cursor-pointer m-0 <%= button[:class] %>">
|
||||
<button type="submit" class="<%= button_classes %>">
|
||||
<%= button[:text] %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% close_on_click = if button.key?(:close)
|
||||
button[:close]
|
||||
else
|
||||
button[:action].blank? || button[:action].to_s.include?("modal#close")
|
||||
end %>
|
||||
<button
|
||||
type="button"
|
||||
data-modal-close="<%= close_on_click %>"
|
||||
class="<%= button_classes %>"
|
||||
>
|
||||
<%= button[:text] %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%
|
||||
end
|
||||
end
|
||||
%>
|
||||
|
||||
<div
|
||||
id="<%= modal_id %>"
|
||||
class="hidden"
|
||||
data-bits-modal
|
||||
data-modal-title="<%= title %>"
|
||||
data-modal-description="<%= description.to_s %>"
|
||||
data-modal-max-width="<%= max_width %>"
|
||||
>
|
||||
<% if icon_svg %>
|
||||
<template data-modal-icon>
|
||||
<svg class="h-10 w-10 <%= icon_color %>" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<%= sanitize icon_svg, tags: %w[path circle rect line polyline polygon], attributes: %w[d cx cy r x y x1 y1 x2 y2 points stroke-linecap stroke-linejoin stroke-width fill] %>
|
||||
</svg>
|
||||
</template>
|
||||
<% end %>
|
||||
|
||||
<% if custom_html.present? %>
|
||||
<template data-modal-custom>
|
||||
<%= custom_html %>
|
||||
</template>
|
||||
<% end %>
|
||||
|
||||
<% if actions_html.present? %>
|
||||
<template data-modal-actions>
|
||||
<%= actions_html %>
|
||||
</template>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
21
bun.lock
21
bun.lock
|
|
@ -13,6 +13,7 @@
|
|||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tsconfig/svelte": "^5.0.7",
|
||||
"axios": "^1.13.2",
|
||||
"bits-ui": "^2.15.5",
|
||||
"d3-scale": "^4.0.2",
|
||||
"layerchart": "^1.0.13",
|
||||
"plur": "^6.0.0",
|
||||
|
|
@ -101,6 +102,8 @@
|
|||
|
||||
"@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" } }],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
|
@ -181,6 +184,8 @@
|
|||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
|
||||
|
||||
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
|
@ -243,6 +248,8 @@
|
|||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"bits-ui": ["bits-ui@2.15.5", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
|
@ -319,6 +326,8 @@
|
|||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
||||
|
|
@ -381,6 +390,8 @@
|
|||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"irregular-plurals": ["irregular-plurals@4.2.0", "", {}, "sha512-bW9UXHL7bnUcNtTo+9ccSngbxc+V40H32IgvdVin0Xs8gbo+AVYD5g/72ce/54Kjfhq66vcZr8H8TKEvsifeOw=="],
|
||||
|
|
@ -437,6 +448,8 @@
|
|||
|
||||
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
|
@ -515,6 +528,8 @@
|
|||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
|
||||
|
||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
|
@ -523,6 +538,8 @@
|
|||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -531,6 +548,10 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@ Rails.application.routes.draw do
|
|||
post "my/settings/rotate_api_key", to: "settings/access#rotate_api_key", as: :my_settings_rotate_api_key
|
||||
|
||||
namespace :my do
|
||||
resources :heartbeat_imports, only: [ :create, :show ]
|
||||
|
||||
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do
|
||||
member do
|
||||
patch :archive
|
||||
|
|
|
|||
127
package-lock.json
generated
127
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
|||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tsconfig/svelte": "^5.0.7",
|
||||
"axios": "^1.13.2",
|
||||
"bits-ui": "^2.15.5",
|
||||
"d3-scale": "^4.0.2",
|
||||
"layerchart": "^1.0.13",
|
||||
"plur": "^6.0.0",
|
||||
|
|
@ -518,6 +519,16 @@
|
|||
"resolved": "vendor/inertia/packages/svelte",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz",
|
||||
"integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"license": "MIT",
|
||||
|
|
@ -1126,6 +1137,15 @@
|
|||
"vite": "^6.3.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/forms": {
|
||||
"version": "0.5.11",
|
||||
"license": "MIT",
|
||||
|
|
@ -1512,6 +1532,30 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bits-ui": {
|
||||
"version": "2.15.5",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.5.tgz",
|
||||
"integrity": "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.1",
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"esm-env": "^1.1.2",
|
||||
"runed": "^0.35.1",
|
||||
"svelte-toolbelt": "^0.10.6",
|
||||
"tabbable": "^6.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/huntabyte"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@internationalized/date": "^3.8.1",
|
||||
"svelte": "^5.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
|
|
@ -1962,6 +2006,15 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"license": "Apache-2.0",
|
||||
|
|
@ -2349,6 +2402,12 @@
|
|||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
|
|
@ -2783,6 +2842,15 @@
|
|||
"version": "4.17.23",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"license": "MIT",
|
||||
|
|
@ -3331,6 +3399,30 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/runed": {
|
||||
"version": "0.35.1",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
|
||||
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
"https://github.com/sponsors/tglide"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"esm-env": "^1.0.0",
|
||||
"lz-string": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"svelte": "^5.7.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
|
|
@ -3360,6 +3452,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-to-object": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
|
||||
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
|
|
@ -3454,6 +3555,32 @@
|
|||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-toolbelt": {
|
||||
"version": "0.10.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
|
||||
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte"
|
||||
],
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"runed": "^0.35.1",
|
||||
"style-to-object": "^1.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"pnpm": ">=8.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tsconfig/svelte": "^5.0.7",
|
||||
"axios": "^1.13.2",
|
||||
"bits-ui": "^2.15.5",
|
||||
"d3-scale": "^4.0.2",
|
||||
"layerchart": "^1.0.13",
|
||||
"plur": "^6.0.0",
|
||||
|
|
|
|||
118
test/controllers/my/heartbeat_imports_controller_test.rb
Normal file
118
test/controllers/my/heartbeat_imports_controller_test.rb
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
require "test_helper"
|
||||
|
||||
class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "create rejects guests" do
|
||||
post my_heartbeat_imports_path
|
||||
|
||||
assert_response :unauthorized
|
||||
assert_equal "You must be logged in to view this page.", JSON.parse(response.body)["error"]
|
||||
end
|
||||
|
||||
test "create rejects non-development environment" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
sign_in_as(user)
|
||||
|
||||
post my_heartbeat_imports_path
|
||||
|
||||
assert_response :forbidden
|
||||
assert_equal "Heartbeat import is only available in development.", JSON.parse(response.body)["error"]
|
||||
end
|
||||
|
||||
test "show rejects non-development environment" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
sign_in_as(user)
|
||||
|
||||
get my_heartbeat_import_path("import-123")
|
||||
|
||||
assert_response :forbidden
|
||||
assert_equal "Heartbeat import is only available in development.", JSON.parse(response.body)["error"]
|
||||
end
|
||||
|
||||
test "create returns error when file is missing" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
sign_in_as(user)
|
||||
|
||||
with_development_env do
|
||||
post my_heartbeat_imports_path
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "pls select a file to import", JSON.parse(response.body)["error"]
|
||||
end
|
||||
|
||||
test "create returns error when file type is invalid" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
sign_in_as(user)
|
||||
|
||||
with_development_env do
|
||||
post my_heartbeat_imports_path, params: {
|
||||
heartbeat_file: uploaded_file(filename: "heartbeats.txt", content_type: "text/plain", content: "hello")
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "pls upload only json (download from the button above it)", JSON.parse(response.body)["error"]
|
||||
end
|
||||
|
||||
test "create starts import and returns status" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
sign_in_as(user)
|
||||
expected_status = { "state" => "queued", "progress_percent" => 0 }
|
||||
|
||||
with_development_env do
|
||||
with_runner_stubs(start_return: "import-123", status_return: expected_status) do
|
||||
post my_heartbeat_imports_path, params: {
|
||||
heartbeat_file: uploaded_file
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :accepted
|
||||
body = JSON.parse(response.body)
|
||||
assert_equal "import-123", body["import_id"]
|
||||
assert_equal "queued", body.dig("status", "state")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sign_in_as(user)
|
||||
token = user.sign_in_tokens.create!(auth_type: :email)
|
||||
get auth_token_path(token: token.token)
|
||||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
|
||||
def with_development_env
|
||||
rails_singleton = class << Rails; self; end
|
||||
rails_singleton.alias_method :__original_env_for_test, :env
|
||||
rails_singleton.define_method(:env) { ActiveSupport::StringInquirer.new("development") }
|
||||
yield
|
||||
ensure
|
||||
rails_singleton.remove_method :env
|
||||
rails_singleton.alias_method :env, :__original_env_for_test
|
||||
rails_singleton.remove_method :__original_env_for_test
|
||||
end
|
||||
|
||||
def with_runner_stubs(start_return:, status_return:)
|
||||
runner_singleton = class << HeartbeatImportRunner; self; end
|
||||
runner_singleton.alias_method :__original_start_for_test, :start
|
||||
runner_singleton.alias_method :__original_status_for_test, :status
|
||||
runner_singleton.define_method(:start) { |**| start_return }
|
||||
runner_singleton.define_method(:status) { |**| status_return }
|
||||
yield
|
||||
ensure
|
||||
runner_singleton.remove_method :start
|
||||
runner_singleton.remove_method :status
|
||||
runner_singleton.alias_method :start, :__original_start_for_test
|
||||
runner_singleton.alias_method :status, :__original_status_for_test
|
||||
runner_singleton.remove_method :__original_start_for_test
|
||||
runner_singleton.remove_method :__original_status_for_test
|
||||
end
|
||||
|
||||
def uploaded_file(filename: "heartbeats.json", content_type: "application/json", content: '{"heartbeats":[]}')
|
||||
Rack::Test::UploadedFile.new(
|
||||
StringIO.new(content),
|
||||
content_type,
|
||||
original_filename: filename
|
||||
)
|
||||
end
|
||||
end
|
||||
54
test/controllers/my/project_repo_mappings_controller_test.rb
Normal file
54
test/controllers/my/project_repo_mappings_controller_test.rb
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
require "test_helper"
|
||||
|
||||
class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "index redirects guests" do
|
||||
get my_projects_path
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "index renders projects page with deferred props" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
user.project_repo_mappings.create!(project_name: "alpha")
|
||||
create_project_heartbeats(user, "alpha")
|
||||
|
||||
sign_in_as(user)
|
||||
get my_projects_path
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "\"component\":\"Projects/Index\""
|
||||
assert_includes response.body, "\"deferredProps\":{\"default\":[\"projects_data\"]}"
|
||||
assert_includes response.body, "\"show_archived\":false"
|
||||
assert_includes response.body, "\"total_projects\":1"
|
||||
end
|
||||
|
||||
test "index supports archived view state" do
|
||||
user = User.create!(timezone: "UTC")
|
||||
mapping = user.project_repo_mappings.create!(project_name: "beta")
|
||||
mapping.archive!
|
||||
create_project_heartbeats(user, "beta")
|
||||
|
||||
sign_in_as(user)
|
||||
get my_projects_path(show_archived: true)
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "\"component\":\"Projects/Index\""
|
||||
assert_includes response.body, "\"show_archived\":true"
|
||||
assert_includes response.body, "\"total_projects\":1"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_project_heartbeats(user, project_name)
|
||||
now = Time.current.to_i
|
||||
Heartbeat.create!(user: user, project: project_name, category: "coding", time: now - 1800, source_type: :test_entry)
|
||||
Heartbeat.create!(user: user, project: project_name, category: "coding", time: now, source_type: :test_entry)
|
||||
end
|
||||
|
||||
def sign_in_as(user)
|
||||
token = user.sign_in_tokens.create!(auth_type: :email)
|
||||
get auth_token_path(token: token.token)
|
||||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -42,6 +42,218 @@ class ProjectStatsQueryTest < ActiveSupport::TestCase
|
|||
assert_equal [ "new_project" ], query.project_names
|
||||
end
|
||||
|
||||
test "project names supports since filter with default end time" do
|
||||
create_heartbeat(project: "old_project", time: 20.days.ago.to_f)
|
||||
create_heartbeat(project: "new_project", time: 2.days.ago.to_f)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {
|
||||
since: 7.days.ago.iso8601
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal [ "new_project" ], query.project_names
|
||||
end
|
||||
|
||||
test "project details supports numeric default start values" do
|
||||
in_range_time = 3.days.ago.to_f
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: in_range_time)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {},
|
||||
default_discovery_start: 0,
|
||||
default_stats_start: 0
|
||||
)
|
||||
|
||||
project = query.project_details(names: [ "alpha" ]).first
|
||||
|
||||
assert_equal "alpha", project[:name]
|
||||
assert_equal Time.at(in_range_time).utc.strftime("%Y-%m-%dT%H:%M:%SZ"), project[:most_recent_heartbeat]
|
||||
end
|
||||
|
||||
test "project names excludes archived projects unless include_archived is true" do
|
||||
create_heartbeat(project: "active_project", time: 2.days.ago.to_f)
|
||||
create_heartbeat(project: "archived_project", time: 2.days.ago.to_f)
|
||||
|
||||
archived_mapping = ProjectRepoMapping.create!(user: @user, project_name: "archived_project")
|
||||
archived_mapping.archive!
|
||||
|
||||
excluded = ProjectStatsQuery.new(user: @user, params: {})
|
||||
included = ProjectStatsQuery.new(user: @user, params: {}, include_archived: true)
|
||||
|
||||
assert_equal [ "active_project" ], excluded.project_names
|
||||
assert_equal [ "active_project", "archived_project" ], included.project_names.sort
|
||||
end
|
||||
|
||||
test "project details uses projects csv params when explicit names are not provided" do
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: 2.days.ago.to_f)
|
||||
create_heartbeat(project: "beta", language: "Go", time: 2.days.ago.to_f)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {
|
||||
projects: " alpha, beta,alpha, ,"
|
||||
},
|
||||
default_discovery_start: 0,
|
||||
default_stats_start: 0
|
||||
)
|
||||
|
||||
project_names = query.project_details.map { |project| project[:name] }
|
||||
assert_equal [ "alpha", "beta" ], project_names.sort
|
||||
end
|
||||
|
||||
test "project details normalizes explicit names list" do
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: 2.days.ago.to_f)
|
||||
create_heartbeat(project: "beta", language: "Ruby", time: 2.days.ago.to_f)
|
||||
|
||||
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
|
||||
|
||||
details = query.project_details(names: [ " alpha ", "alpha", "", nil, :beta ])
|
||||
names = details.map { |project| project[:name] }
|
||||
|
||||
assert_equal [ "alpha", "beta" ], names.sort
|
||||
end
|
||||
|
||||
test "project details computes total_seconds and returns heartbeat metadata" do
|
||||
base = 5.days.ago.to_f
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: base)
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: base + 30)
|
||||
create_heartbeat(project: "alpha", language: "TypeScript", time: base + 90)
|
||||
create_heartbeat(project: "alpha", language: nil, time: base + 120)
|
||||
|
||||
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
|
||||
|
||||
project = query.project_details(names: [ "alpha" ]).first
|
||||
|
||||
assert_equal 120, project[:total_seconds]
|
||||
assert_equal 4, project[:total_heartbeats]
|
||||
assert_equal [ "Ruby", "TypeScript" ], project[:languages].sort
|
||||
assert_equal formatted_time(base), project[:first_heartbeat]
|
||||
assert_equal formatted_time(base + 120), project[:last_heartbeat]
|
||||
assert_equal project[:last_heartbeat], project[:most_recent_heartbeat]
|
||||
end
|
||||
|
||||
test "project details sorts projects by total_seconds descending" do
|
||||
base = 4.days.ago.to_f
|
||||
create_heartbeat(project: "alpha", time: base)
|
||||
create_heartbeat(project: "alpha", time: base + 90)
|
||||
|
||||
create_heartbeat(project: "beta", time: base)
|
||||
create_heartbeat(project: "beta", time: base + 20)
|
||||
|
||||
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
|
||||
|
||||
details = query.project_details(names: [ "alpha", "beta" ])
|
||||
assert_equal [ "alpha", "beta" ], details.map { |project| project[:name] }
|
||||
end
|
||||
|
||||
test "project details ignores out-of-range timestamps from with_valid_timestamps scope" do
|
||||
valid_time = 2.days.ago.to_f
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: valid_time)
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: -1)
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: 253402300800)
|
||||
|
||||
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
|
||||
project = query.project_details(names: [ "alpha" ]).first
|
||||
|
||||
assert_equal 1, project[:total_heartbeats]
|
||||
assert_equal 0, project[:total_seconds]
|
||||
assert_equal formatted_time(valid_time), project[:first_heartbeat]
|
||||
assert_equal formatted_time(valid_time), project[:last_heartbeat]
|
||||
end
|
||||
|
||||
test "project names supports until_date alias" do
|
||||
create_heartbeat(project: "older_project", time: 20.days.ago.to_f)
|
||||
create_heartbeat(project: "newer_project", time: 2.days.ago.to_f)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {
|
||||
since: 30.days.ago.iso8601,
|
||||
until_date: 7.days.ago.iso8601
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal [ "older_project" ], query.project_names
|
||||
end
|
||||
|
||||
test "invalid date params fall back to provided defaults for project details" do
|
||||
in_range_time = 3.days.ago.to_f
|
||||
out_of_range_time = 20.days.ago.to_f
|
||||
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: in_range_time)
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: out_of_range_time)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {
|
||||
start: "not-a-date",
|
||||
end: "still-not-a-date"
|
||||
},
|
||||
default_stats_start: 7.days.ago,
|
||||
default_stats_end: 1.day.ago
|
||||
)
|
||||
|
||||
project = query.project_details(names: [ "alpha" ]).first
|
||||
assert_equal 1, project[:total_heartbeats]
|
||||
assert_equal formatted_time(in_range_time), project[:most_recent_heartbeat]
|
||||
end
|
||||
|
||||
test "invalid date params fall back to provided defaults for project names" do
|
||||
create_heartbeat(project: "old_project", time: 20.days.ago.to_f)
|
||||
create_heartbeat(project: "new_project", time: 2.days.ago.to_f)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {
|
||||
since: "not-a-date",
|
||||
until: "still-not-a-date"
|
||||
},
|
||||
default_discovery_start: 7.days.ago,
|
||||
default_discovery_end: 1.day.ago
|
||||
)
|
||||
|
||||
assert_equal [ "new_project" ], query.project_names
|
||||
end
|
||||
|
||||
test "project discovery uses since over start when both are provided" do
|
||||
create_heartbeat(project: "old_project", time: 10.days.ago.to_f)
|
||||
create_heartbeat(project: "new_project", time: 2.days.ago.to_f)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {
|
||||
since: 3.days.ago.iso8601,
|
||||
start: 30.days.ago.iso8601
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal [ "new_project" ], query.project_names
|
||||
end
|
||||
|
||||
test "project details supports start_date and end_date aliases" do
|
||||
in_range_time = 3.days.ago.to_f
|
||||
out_of_range_time = 15.days.ago.to_f
|
||||
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: in_range_time)
|
||||
create_heartbeat(project: "alpha", language: "Ruby", time: out_of_range_time)
|
||||
|
||||
query = ProjectStatsQuery.new(
|
||||
user: @user,
|
||||
params: {
|
||||
start_date: 7.days.ago.iso8601,
|
||||
end_date: 1.day.ago.iso8601
|
||||
}
|
||||
)
|
||||
|
||||
project = query.project_details(names: [ "alpha" ]).first
|
||||
|
||||
assert_equal 1, project[:total_heartbeats]
|
||||
assert_equal formatted_time(in_range_time), project[:most_recent_heartbeat]
|
||||
end
|
||||
|
||||
test "project details hides archived projects unless include_archived is true" do
|
||||
create_heartbeat(project: "archived_project", language: "Ruby", time: 2.days.ago.to_f)
|
||||
mapping = ProjectRepoMapping.create!(user: @user, project_name: "archived_project")
|
||||
|
|
@ -66,4 +278,8 @@ class ProjectStatsQueryTest < ActiveSupport::TestCase
|
|||
time: time
|
||||
)
|
||||
end
|
||||
|
||||
def formatted_time(time_value)
|
||||
Time.at(time_value).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue