mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 19:55:16 +00:00
* 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
596 lines
21 KiB
Svelte
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>
|