feat: add project updates feed and dedicated page
Proxy and sanitize Gitea repository data via /api/project-updates Display recent commits and releases preview on the Home page Add /project-updates route for paginated commit history
This commit is contained in:
@@ -1,25 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import {
|
||||
iconAction,
|
||||
iconAutomation,
|
||||
iconChevronRight,
|
||||
iconChecklist,
|
||||
iconClothes,
|
||||
iconDish,
|
||||
iconDreamIsland,
|
||||
iconEvent,
|
||||
iconExternal,
|
||||
iconGitCommit,
|
||||
iconHabitat,
|
||||
iconItem,
|
||||
iconLife,
|
||||
iconPokemon,
|
||||
iconRecipe
|
||||
} from '../icons';
|
||||
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const projectCommitPageSize = 5;
|
||||
const projectUpdates = ref<ProjectUpdates | null>(null);
|
||||
const projectUpdatesLoading = ref(true);
|
||||
const projectCommits = ref<ProjectUpdateCommit[]>([]);
|
||||
|
||||
const primarySections = computed(() => [
|
||||
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
|
||||
@@ -42,6 +51,17 @@ const futureSections = computed(() => [
|
||||
{ key: 'clothes', to: '/clothes', icon: iconClothes }
|
||||
]);
|
||||
|
||||
const latestReleases = computed(() => projectUpdates.value?.releases.slice(0, 3) ?? []);
|
||||
const showProjectUpdates = computed(
|
||||
() => projectUpdatesLoading.value || projectCommits.value.length > 0 || latestReleases.value.length > 0
|
||||
);
|
||||
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
||||
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectUpdates();
|
||||
});
|
||||
|
||||
function sectionTitleKey(key: string) {
|
||||
return `pages.home.sections.${key}.title`;
|
||||
}
|
||||
@@ -49,6 +69,34 @@ function sectionTitleKey(key: string) {
|
||||
function sectionDescriptionKey(key: string) {
|
||||
return `pages.home.sections.${key}.description`;
|
||||
}
|
||||
|
||||
async function loadProjectUpdates(): Promise<void> {
|
||||
projectUpdatesLoading.value = true;
|
||||
try {
|
||||
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||
projectUpdates.value = updates;
|
||||
projectCommits.value = updates.commits.items;
|
||||
} catch {
|
||||
projectUpdates.value = null;
|
||||
projectCommits.value = [];
|
||||
} finally {
|
||||
projectUpdatesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,6 +193,84 @@ function sectionDescriptionKey(key: string) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showProjectUpdates" class="home-section home-project-updates" aria-labelledby="home-project-updates-title">
|
||||
<div class="home-section__header">
|
||||
<span class="page-kicker">{{ t('pages.home.projectUpdatesKicker') }}</span>
|
||||
<h2 id="home-project-updates-title">{{ t('pages.home.projectUpdatesTitle') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="home-project-updates__panel">
|
||||
<div v-if="projectUpdates" class="home-project-updates__repo">
|
||||
<span class="home-project-updates__repo-label">{{ t('pages.home.projectUpdatesRepo') }}</span>
|
||||
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconGitCommit" class="ui-icon" aria-hidden="true" />
|
||||
{{ projectUpdates.repository.fullName }}
|
||||
</a>
|
||||
<span v-if="repositoryUpdatedAt" class="home-project-updates__updated">
|
||||
{{ t('pages.home.projectUpdatesUpdatedAt', { date: repositoryUpdatedAt }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="projectUpdatesLoading" class="home-project-updates__skeleton">
|
||||
<Skeleton width="42%" />
|
||||
<Skeleton width="76%" />
|
||||
<Skeleton width="64%" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="projectUpdates" class="home-project-updates__content">
|
||||
<div v-if="latestReleases.length" class="home-project-updates__group">
|
||||
<h3>{{ t('pages.home.projectUpdatesReleases') }}</h3>
|
||||
<ol class="home-project-updates__list">
|
||||
<li v-for="release in latestReleases" :key="release.tagName" class="home-project-updates__item">
|
||||
<div class="home-project-updates__commit">
|
||||
<div class="home-project-updates__title">
|
||||
<span class="home-project-updates__sha">{{ release.tagName }}</span>
|
||||
<strong>{{ release.name }}</strong>
|
||||
</div>
|
||||
<div v-if="release.publishedAt" class="home-project-updates__meta">
|
||||
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<a class="ui-button ui-button--ghost home-project-updates__link" :href="release.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.home.projectUpdatesViewRelease') }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="projectCommits.length" class="home-project-updates__group">
|
||||
<h3>{{ t('pages.home.projectUpdatesCommits') }}</h3>
|
||||
<ol class="home-project-updates__list">
|
||||
<li v-for="commit in projectCommits" :key="commit.sha" class="home-project-updates__item">
|
||||
<div class="home-project-updates__commit">
|
||||
<div class="home-project-updates__title">
|
||||
<span class="home-project-updates__sha">{{ commit.shortSha }}</span>
|
||||
<strong>{{ commit.title }}</strong>
|
||||
</div>
|
||||
<div class="home-project-updates__meta">
|
||||
<span>{{ commit.authorName }}</span>
|
||||
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<a class="ui-button ui-button--ghost home-project-updates__link" :href="commit.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.home.projectUpdatesViewCommit') }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div v-if="showProjectUpdatesViewAll" class="home-project-updates__actions">
|
||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/project-updates">
|
||||
<Icon :icon="iconChevronRight" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.home.projectUpdatesViewAll') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-section" aria-labelledby="home-future-title">
|
||||
<div class="home-section__header">
|
||||
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>
|
||||
|
||||
284
frontend/src/views/ProjectUpdatesView.vue
Normal file
284
frontend/src/views/ProjectUpdatesView.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import {
|
||||
iconChevronDown,
|
||||
iconChevronUp,
|
||||
iconExternal,
|
||||
iconGitCommit,
|
||||
iconWarning
|
||||
} from '../icons';
|
||||
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const projectCommitPageSize = 10;
|
||||
const projectUpdates = ref<ProjectUpdates | null>(null);
|
||||
const projectCommits = ref<ProjectUpdateCommit[]>([]);
|
||||
const projectCommitCursor = ref<string | null>(null);
|
||||
const projectHasMoreCommits = ref(false);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const loadError = ref(false);
|
||||
const loadMorePaused = ref(false);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
const expandedCommitShas = ref<Set<string>>(new Set());
|
||||
let projectUpdatesObserver: IntersectionObserver | null = null;
|
||||
|
||||
const releases = computed(() => projectUpdates.value?.releases ?? []);
|
||||
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectUpdates();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectProjectUpdatesObserver();
|
||||
});
|
||||
|
||||
async function loadProjectUpdates(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadingMore.value = false;
|
||||
loadError.value = false;
|
||||
loadMorePaused.value = false;
|
||||
projectCommitCursor.value = null;
|
||||
projectHasMoreCommits.value = false;
|
||||
expandedCommitShas.value = new Set();
|
||||
|
||||
try {
|
||||
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||
projectUpdates.value = updates;
|
||||
projectCommits.value = updates.commits.items;
|
||||
projectCommitCursor.value = updates.commits.nextCursor;
|
||||
projectHasMoreCommits.value = updates.commits.hasMore;
|
||||
} catch {
|
||||
projectUpdates.value = null;
|
||||
projectCommits.value = [];
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreProjectUpdates(): Promise<void> {
|
||||
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = projectCommitCursor.value;
|
||||
if (!cursor) {
|
||||
projectHasMoreCommits.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingMore.value = true;
|
||||
|
||||
try {
|
||||
const updates = await api.projectUpdates({
|
||||
cursor,
|
||||
limit: projectCommitPageSize
|
||||
});
|
||||
projectUpdates.value = updates;
|
||||
const existingShas = new Set(projectCommits.value.map((commit) => commit.sha));
|
||||
projectCommits.value = [...projectCommits.value, ...updates.commits.items.filter((commit) => !existingShas.has(commit.sha))];
|
||||
projectCommitCursor.value = updates.commits.nextCursor;
|
||||
projectHasMoreCommits.value = updates.commits.hasMore;
|
||||
} catch {
|
||||
loadMorePaused.value = true;
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function retryLoadMore(): void {
|
||||
loadMorePaused.value = false;
|
||||
void loadMoreProjectUpdates();
|
||||
}
|
||||
|
||||
function toggleCommitMessage(sha: string): void {
|
||||
const nextExpanded = new Set(expandedCommitShas.value);
|
||||
if (nextExpanded.has(sha)) {
|
||||
nextExpanded.delete(sha);
|
||||
} else {
|
||||
nextExpanded.add(sha);
|
||||
}
|
||||
expandedCommitShas.value = nextExpanded;
|
||||
}
|
||||
|
||||
function isCommitExpanded(sha: string): boolean {
|
||||
return expandedCommitShas.value.has(sha);
|
||||
}
|
||||
|
||||
function disconnectProjectUpdatesObserver(): void {
|
||||
projectUpdatesObserver?.disconnect();
|
||||
projectUpdatesObserver = null;
|
||||
}
|
||||
|
||||
function observeProjectUpdatesLoadMore(): void {
|
||||
disconnectProjectUpdatesObserver();
|
||||
|
||||
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value || !loadMoreSentinel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
void loadMoreProjectUpdates();
|
||||
return;
|
||||
}
|
||||
|
||||
projectUpdatesObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
void loadMoreProjectUpdates();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '360px 0px' }
|
||||
);
|
||||
projectUpdatesObserver.observe(loadMoreSentinel.value);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
watch([loadMoreSentinel, projectHasMoreCommits, loading, loadingMore, loadMorePaused], observeProjectUpdatesLoadMore, {
|
||||
flush: 'post'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack project-updates-page">
|
||||
<PageHeader :title="t('pages.projectUpdates.title')" :subtitle="t('pages.projectUpdates.subtitle')">
|
||||
<template #kicker>{{ t('pages.projectUpdates.kicker') }}</template>
|
||||
<template v-if="projectUpdates" #actions>
|
||||
<a class="ui-button ui-button--ghost" :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.projectUpdates.openRepository') }}
|
||||
</a>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<section v-if="loading" class="project-updates-panel" aria-busy="true" :aria-label="t('pages.projectUpdates.loading')">
|
||||
<Skeleton width="40%" height="22px" />
|
||||
<Skeleton width="76%" />
|
||||
<Skeleton width="64%" />
|
||||
<Skeleton variant="box" height="78px" />
|
||||
<Skeleton variant="box" height="78px" />
|
||||
<Skeleton variant="box" height="78px" />
|
||||
</section>
|
||||
|
||||
<section v-else-if="loadError" class="project-updates-panel">
|
||||
<p class="status">{{ t('errors.loadFailed') }}</p>
|
||||
<button class="ui-button ui-button--ghost" type="button" @click="loadProjectUpdates">
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.projectUpdates.retry') }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<template v-else-if="projectUpdates">
|
||||
<section class="project-updates-panel">
|
||||
<div class="project-updates-repo">
|
||||
<span class="project-updates-repo__icon" aria-hidden="true">
|
||||
<Icon :icon="iconGitCommit" class="ui-icon" />
|
||||
</span>
|
||||
<div class="project-updates-repo__copy">
|
||||
<span>{{ t('pages.projectUpdates.sourceRepository') }}</span>
|
||||
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||
{{ projectUpdates.repository.fullName }}
|
||||
</a>
|
||||
</div>
|
||||
<span v-if="repositoryUpdatedAt" class="project-updates-repo__meta">
|
||||
{{ t('pages.projectUpdates.updatedAt', { date: repositoryUpdatedAt }) }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="releases.length" class="project-updates-panel">
|
||||
<h2>{{ t('pages.projectUpdates.releases') }}</h2>
|
||||
<ol class="project-updates-list">
|
||||
<li v-for="release in releases" :key="release.tagName" class="project-updates-list__item">
|
||||
<div class="project-updates-list__main">
|
||||
<div class="project-updates-list__title">
|
||||
<span class="project-updates-list__sha">{{ release.tagName }}</span>
|
||||
<strong>{{ release.name }}</strong>
|
||||
</div>
|
||||
<div v-if="release.publishedAt" class="project-updates-list__meta">
|
||||
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<a class="ui-button ui-button--ghost ui-button--small" :href="release.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.projectUpdates.viewRelease') }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="project-updates-panel">
|
||||
<h2>{{ t('pages.projectUpdates.commits') }}</h2>
|
||||
<ol v-if="projectCommits.length" class="project-updates-list">
|
||||
<li v-for="commit in projectCommits" :key="commit.sha" class="project-updates-list__item project-updates-list__item--commit">
|
||||
<div class="project-updates-list__row">
|
||||
<div class="project-updates-list__main">
|
||||
<div class="project-updates-list__title">
|
||||
<span class="project-updates-list__sha">{{ commit.shortSha }}</span>
|
||||
<strong>{{ commit.title }}</strong>
|
||||
</div>
|
||||
<div class="project-updates-list__meta">
|
||||
<span>{{ commit.authorName }}</span>
|
||||
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-updates-list__actions">
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:aria-expanded="isCommitExpanded(commit.sha)"
|
||||
@click="toggleCommitMessage(commit.sha)"
|
||||
>
|
||||
<Icon :icon="isCommitExpanded(commit.sha) ? iconChevronUp : iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||
{{ isCommitExpanded(commit.sha) ? t('pages.projectUpdates.collapseMessage') : t('pages.projectUpdates.expandMessage') }}
|
||||
</button>
|
||||
<a class="ui-button ui-button--ghost ui-button--small" :href="commit.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.projectUpdates.viewCommit') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isCommitExpanded(commit.sha)" class="project-updates-message">
|
||||
<span>{{ t('pages.projectUpdates.commitMessage') }}</span>
|
||||
<pre>{{ commit.message }}</pre>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="meta-line">{{ t('pages.projectUpdates.empty') }}</p>
|
||||
|
||||
<div v-if="loadingMore" class="project-updates-more-skeleton">
|
||||
<Skeleton width="82%" />
|
||||
<Skeleton width="58%" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectHasMoreCommits" ref="loadMoreSentinel" class="project-updates-sentinel" aria-hidden="true"></div>
|
||||
|
||||
<div v-if="loadMorePaused && projectHasMoreCommits" class="project-updates-actions">
|
||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="retryLoadMore">
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.projectUpdates.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user