hackatime/app/javascript/pages/Projects/Index.svelte
Mahad Kalam 523d7e6ffb
Bugfixes (#1101)
* Progress bar should be primary colour

* Projects page shouldn't refresh on archive

* Fix GitHub syncing

* Add HCA lookup

* Fix .ad and .mdx

* Misc.

* Badges can use owner/repo

* Format + make Zeitwerk happy
2026-03-23 12:51:04 +00:00

596 lines
21 KiB
Svelte

<script lang="ts">
import { Link, router } 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 confirmStatusChange = () => {
if (!pendingStatusAction) return;
router.patch(
pendingStatusAction.path,
{},
{
preserveScroll: true,
onSuccess: () => {
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-surface-content">
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-xs 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>
<Button
type="button"
variant="primary"
class="h-10 w-full text-on-primary"
onclick={confirmStatusChange}
>
{pendingStatusAction.confirmLabel}
</Button>
</div>
{/if}
{/snippet}
</Modal>