ytptube/ui/app/pages/changelog.vue
2026-03-25 22:59:53 +03:00

280 lines
8.8 KiB
Vue

<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-git-commit-horizontal" class="size-5 text-toned" />
<span>CHANGELOG</span>
</div>
<p class="text-sm text-toned">
Latest project changes, loaded remotely when available and falling back to the bundled
changelog file.
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="toggleFilter && logs.length > 0" class="relative w-full sm:w-80">
<span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
>
<UIcon name="i-lucide-filter" class="size-4" />
</span>
<input
id="filter"
v-model.lazy="query"
type="search"
placeholder="Filter changelog entries"
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/>
</div>
<UButton
v-if="logs.length > 0"
color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'"
size="sm"
icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter"
>
<span v-if="!isMobile">Filter</span>
</UButton>
<USwitch
v-model="latestOnly"
color="primary"
size="sm"
:label="latestOnly ? 'Latest Only' : 'All Loaded'"
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
/>
</div>
</div>
<UAlert
v-if="isLoading"
color="info"
variant="soft"
icon="i-lucide-loader-circle"
title="Loading"
description="Loading data. Please wait..."
/>
<template v-else>
<div v-if="filteredLogs.length > 0" class="space-y-4">
<UPageCard v-for="log in filteredLogs" :key="log.tag" variant="outline" :ui="pageCardUi">
<template #body>
<div class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<div
class="inline-flex items-center gap-2 text-base font-semibold text-highlighted"
>
<UIcon name="i-lucide-git-branch" class="size-4 text-toned" />
<span>{{ log.tag }}</span>
</div>
<UBadge v-if="isInstalled(log)" color="success" variant="soft" size="sm">
Installed
</UBadge>
</div>
<p v-if="log.date" class="text-xs text-toned">
<UTooltip :text="`Release Date: ${log.date}`">
<span>{{ moment(log.date).fromNow() }}</span>
</UTooltip>
</p>
</div>
<UBadge color="neutral" variant="soft" size="sm">
{{ log.commits?.length || 0 }} commits
</UBadge>
</div>
<div class="space-y-3 border-t border-default pt-4">
<article
v-for="commit in log.commits"
:key="commit.sha"
class="flex flex-col gap-2 rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<p class="min-w-0 flex-1 text-sm text-default">
<span class="font-semibold text-highlighted">
{{ ucFirst(commit.message).replace(/\.$/, '') }}.
</span>
</p>
<div class="flex shrink-0 items-center gap-2">
<NuxtLink
:to="`${REPO}/commit/${commit.full_sha}`"
target="_blank"
class="text-xs font-medium text-primary hover:underline"
>
{{ commit.sha }}
</NuxtLink>
<UIcon
v-if="commit.full_sha === app_sha"
name="i-lucide-check"
class="size-4 text-success"
/>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-toned">
<span>{{ commit.author }}</span>
<UTooltip :text="`SHA: ${commit.full_sha} - Date: ${commit.date}`">
<span>{{ moment(commit.date).fromNow() }}</span>
</UTooltip>
</div>
</article>
</div>
</div>
</template>
</UPageCard>
</div>
<div v-else class="space-y-3">
<UAlert
v-if="query"
color="warning"
variant="soft"
icon="i-lucide-search"
title="No Results"
:description="`No changelog entries found for the query: ${query}.`"
/>
<UButton v-if="query" color="neutral" variant="outline" size="sm" @click="query = ''">
Clear filter
</UButton>
<UAlert
v-else
color="warning"
variant="soft"
icon="i-lucide-circle-alert"
title="No changelog entries"
description="No changelog data is available right now."
/>
</div>
</template>
</main>
</template>
<script setup lang="ts">
import moment from 'moment';
import { useStorage } from '@vueuse/core';
import type { changelogs, changeset } from '~/types/changelogs';
import { request, ucFirst, uri } from '~/utils';
const toast = useNotification();
const config = useConfigStore();
const isMobile = useMediaQuery({ maxWidth: 1024 });
useHead({ title: 'CHANGELOG' });
const PROJECT = 'ytptube';
const REPO = `https://github.com/arabcoders/${PROJECT}`;
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`;
const DEFAULT_LIMIT = 10;
const logs = ref<changelogs>([]);
const app_version = ref('');
const app_branch = ref('');
const app_sha = ref('');
const isLoading = ref(true);
const query = ref('');
const toggleFilter = ref(false);
const latestOnly = useStorage<boolean>('changelog_latest_only', true);
const pageCardUi = {
root: 'w-full bg-default',
container: 'w-full p-4 sm:p-5',
wrapper: 'w-full items-stretch',
body: 'w-full',
};
watch(toggleFilter, () => {
if (!toggleFilter.value) {
query.value = '';
}
});
const visibleLogs = computed<changelogs>(() =>
latestOnly.value ? logs.value.slice(0, DEFAULT_LIMIT) : logs.value,
);
const filteredLogs = computed<changelogs>(() => {
const q = query.value?.toLowerCase();
if (!q) {
return visibleLogs.value;
}
return visibleLogs.value
.map((log) => {
const tagMatches = log.tag.toLowerCase().includes(q);
const filteredCommits =
log.commits?.filter(
(commit) =>
commit.message.toLowerCase().includes(q) ||
commit.author.toLowerCase().includes(q) ||
commit.full_sha.toLowerCase().includes(q),
) ?? [];
if (tagMatches || filteredCommits.length > 0) {
return { ...log, commits: tagMatches ? log.commits : filteredCommits };
}
return null;
})
.filter((log): log is changeset => log !== null);
});
const loadContent = async (): Promise<void> => {
if (app_version.value === '' || logs.value.length > 0) {
return;
}
try {
try {
const changes = await fetch(
REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value),
);
logs.value = await changes.json();
} catch (error) {
console.error(error);
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json();
}
} catch (error: any) {
console.error(error);
toast.error(`Failed to fetch changelog. ${error.message}`);
} finally {
isLoading.value = false;
}
};
const isInstalled = (log: changeset): boolean => {
const installed = String(app_sha.value);
if (log.full_sha.startsWith(installed)) {
return true;
}
for (const commit of log?.commits ?? []) {
if (commit.full_sha.startsWith(installed)) {
return true;
}
}
return false;
};
onMounted(async () => {
await awaiter(config.isLoaded);
app_branch.value = config.app.app_branch;
app_version.value = config.app.app_version;
app_sha.value = config.app.app_commit_sha;
await loadContent();
});
</script>