390 lines
17 KiB
Vue
390 lines
17 KiB
Vue
<template>
|
|
<!-- eslint-disable vue/no-v-html -->
|
|
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
|
|
<!-- header -->
|
|
<div
|
|
class="flex flex-col sm:flex-row sm:items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm gap-4"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg shrink-0">
|
|
<MaterialDesignIcon icon-name="archive" class="size-6 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white truncate">{{ $t("app.archives") }}</h1>
|
|
<p class="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate">
|
|
{{ $t("archives.description") }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 sm:ml-auto">
|
|
<div class="relative flex-1 sm:w-64 md:w-80">
|
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
|
</div>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all text-sm"
|
|
:placeholder="$t('archives.search_placeholder')"
|
|
@input="onSearchInput"
|
|
/>
|
|
</div>
|
|
<button
|
|
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors shrink-0"
|
|
:title="$t('common.refresh')"
|
|
@click="getArchives"
|
|
>
|
|
<MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- content -->
|
|
<div class="flex-1 overflow-y-auto p-4 md:p-6">
|
|
<div v-if="isLoading && archives.length === 0" class="flex flex-col items-center justify-center h-64">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
|
|
<p class="text-gray-500 dark:text-gray-400">{{ $t("archives.loading") }}</p>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="groupedArchives.length === 0"
|
|
class="flex flex-col items-center justify-center h-64 text-center"
|
|
>
|
|
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
|
|
<MaterialDesignIcon icon-name="archive-off" class="size-12" />
|
|
</div>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
|
{{ $t("archives.no_archives_found") }}
|
|
</h3>
|
|
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
|
{{ searchQuery ? $t("archives.adjust_filters") : $t("archives.browse_to_archive") }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-stretch">
|
|
<div v-for="group in groupedArchives" :key="group.destination_hash" class="relative flex">
|
|
<div class="sticky top-6 w-full flex flex-col">
|
|
<div
|
|
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden flex flex-col h-full min-h-[400px]"
|
|
>
|
|
<div
|
|
class="p-5 border-b border-gray-100 dark:border-zinc-800 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-zinc-800 dark:to-zinc-800/50"
|
|
>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span
|
|
class="text-xs font-bold px-3 py-1.5 bg-blue-500 dark:bg-blue-600 text-white rounded-full uppercase tracking-wider shadow-sm"
|
|
>
|
|
{{ group.archives.length }}
|
|
{{ group.archives.length === 1 ? $t("archives.page") : $t("archives.pages") }}
|
|
</span>
|
|
</div>
|
|
<h4
|
|
class="text-base font-bold text-gray-900 dark:text-white mb-1 truncate"
|
|
:title="group.node_name"
|
|
>
|
|
{{ group.node_name }}
|
|
</h4>
|
|
<p class="text-xs text-gray-600 dark:text-gray-400 font-mono truncate">
|
|
{{ group.destination_hash.substring(0, 16) }}...
|
|
</p>
|
|
</div>
|
|
<div class="p-5 pb-6 flex-1 flex flex-col min-h-0">
|
|
<CardStack :items="group.archives" :max-visible="3">
|
|
<template #default="{ item: archive }">
|
|
<div
|
|
class="stacked-card bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg p-4 h-full flex flex-col hover:shadow-xl transition-all duration-200 cursor-pointer group"
|
|
@click="viewArchive(archive)"
|
|
>
|
|
<div class="flex items-start justify-between mb-3">
|
|
<div class="flex-1 min-w-0">
|
|
<p
|
|
class="text-sm font-semibold text-gray-900 dark:text-gray-100 font-mono truncate mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
|
:title="archive.page_path || '/'"
|
|
>
|
|
{{ archive.page_path || "/" }}
|
|
</p>
|
|
<div
|
|
class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"
|
|
>
|
|
<MaterialDesignIcon icon-name="clock-outline" class="size-3" />
|
|
<span>{{ formatDate(archive.created_at) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="ml-3 flex-shrink-0">
|
|
<div
|
|
class="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
<div
|
|
class="text-xs text-gray-700 dark:text-gray-300 line-clamp-5 micron-preview leading-relaxed flex-1 min-h-[120px]"
|
|
v-html="renderPreview(archive)"
|
|
></div>
|
|
<div
|
|
class="mt-3 pt-3 border-t border-gray-100 dark:border-zinc-700 flex items-center justify-between flex-shrink-0"
|
|
>
|
|
<div
|
|
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
|
|
>
|
|
<MaterialDesignIcon icon-name="tag" class="size-3" />
|
|
<span class="font-mono">{{ archive.hash.substring(0, 8) }}</span>
|
|
</div>
|
|
<div
|
|
class="text-xs font-medium text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
|
|
>
|
|
{{ $t("archives.view") }}
|
|
<MaterialDesignIcon icon-name="arrow-right" class="size-3" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</CardStack>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="archives.length > 0" class="mt-8 mb-4 flex items-center justify-between">
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
{{
|
|
$t("archives.showing_range", {
|
|
start: pagination.total_count > 0 ? (pagination.page - 1) * pagination.limit + 1 : 0,
|
|
end: Math.min(pagination.page * pagination.limit, pagination.total_count),
|
|
total: pagination.total_count,
|
|
})
|
|
}}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
:disabled="pagination.page <= 1 || isLoading"
|
|
class="p-2 rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-300 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
|
|
@click="changePage(pagination.page - 1)"
|
|
>
|
|
<MaterialDesignIcon icon-name="chevron-left" class="size-5" />
|
|
</button>
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white px-4">
|
|
{{
|
|
$t("archives.page_of", {
|
|
page: pagination.page,
|
|
total_pages: pagination.total_pages,
|
|
})
|
|
}}
|
|
</span>
|
|
<button
|
|
:disabled="pagination.page >= pagination.total_pages || isLoading"
|
|
class="p-2 rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-300 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
|
|
@click="changePage(pagination.page + 1)"
|
|
>
|
|
<MaterialDesignIcon icon-name="chevron-right" class="size-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
|
import Utils from "../../js/Utils";
|
|
import MicronParser from "micron-parser";
|
|
import CardStack from "../CardStack.vue";
|
|
|
|
export default {
|
|
name: "ArchivesPage",
|
|
components: {
|
|
MaterialDesignIcon,
|
|
CardStack,
|
|
},
|
|
data() {
|
|
return {
|
|
archives: [],
|
|
searchQuery: "",
|
|
isLoading: false,
|
|
searchTimeout: null,
|
|
muParser: new MicronParser(),
|
|
pagination: {
|
|
page: 1,
|
|
limit: 15,
|
|
total_count: 0,
|
|
total_pages: 0,
|
|
},
|
|
};
|
|
},
|
|
computed: {
|
|
groupedArchives() {
|
|
const groups = {};
|
|
|
|
for (const archive of this.archives) {
|
|
const hash = archive.destination_hash;
|
|
if (!groups[hash]) {
|
|
groups[hash] = {
|
|
destination_hash: hash,
|
|
node_name: archive.node_name,
|
|
archives: [],
|
|
};
|
|
}
|
|
groups[hash].archives.push(archive);
|
|
}
|
|
|
|
// sort each group by date
|
|
const grouped = Object.values(groups).map((group) => ({
|
|
...group,
|
|
archives: group.archives.sort((a, b) => {
|
|
const dateA = new Date(a.created_at);
|
|
const dateB = new Date(b.created_at);
|
|
return dateB - dateA;
|
|
}),
|
|
}));
|
|
|
|
// sort groups by the date of their most recent archive
|
|
return grouped.sort((a, b) => {
|
|
const dateA = new Date(a.archives[0].created_at);
|
|
const dateB = new Date(b.archives[0].created_at);
|
|
return dateB - dateA;
|
|
});
|
|
},
|
|
},
|
|
mounted() {
|
|
this.getArchives();
|
|
},
|
|
methods: {
|
|
async getArchives() {
|
|
this.isLoading = true;
|
|
try {
|
|
const response = await window.axios.get("/api/v1/nomadnet/archives", {
|
|
params: {
|
|
q: this.searchQuery,
|
|
page: this.pagination.page,
|
|
limit: this.pagination.limit,
|
|
},
|
|
});
|
|
this.archives = response.data.archives;
|
|
this.pagination = response.data.pagination;
|
|
} catch (e) {
|
|
console.error("Failed to load archives:", e);
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
onSearchInput() {
|
|
this.pagination.page = 1; // reset to first page on search
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = setTimeout(() => {
|
|
this.getArchives();
|
|
}, 300);
|
|
},
|
|
async changePage(page) {
|
|
this.pagination.page = page;
|
|
await this.getArchives();
|
|
// scroll to top of content
|
|
const contentElement = document.querySelector(".overflow-y-auto");
|
|
if (contentElement) contentElement.scrollTop = 0;
|
|
},
|
|
viewArchive(archive) {
|
|
this.$router.push({
|
|
name: "nomadnetwork",
|
|
params: { destinationHash: archive.destination_hash },
|
|
query: {
|
|
path: archive.page_path,
|
|
archive_id: archive.id,
|
|
},
|
|
});
|
|
},
|
|
formatDate(dateStr) {
|
|
return Utils.formatTimeAgo(dateStr);
|
|
},
|
|
renderPreview(archive) {
|
|
if (!archive.content) return "";
|
|
|
|
// limit content for preview
|
|
const previewContent = archive.content.substring(0, 500);
|
|
|
|
// convert micron to html if it looks like micron or ends with .mu
|
|
if (archive.page_path?.endsWith(".mu") || archive.content.includes("`")) {
|
|
try {
|
|
return this.muParser.convertMicronToHtml(previewContent);
|
|
} catch {
|
|
return previewContent;
|
|
}
|
|
}
|
|
|
|
return previewContent;
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.line-clamp-3 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.line-clamp-5 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 5;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.stacked-card {
|
|
box-shadow:
|
|
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
|
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.stacked-card:hover {
|
|
box-shadow:
|
|
0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
|
0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.dark .stacked-card {
|
|
box-shadow:
|
|
0 1px 3px 0 rgba(0, 0, 0, 0.3),
|
|
0 1px 2px 0 rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.dark .stacked-card:hover {
|
|
box-shadow:
|
|
0 10px 25px -5px rgba(0, 0, 0, 0.5),
|
|
0 8px 10px -6px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.micron-preview {
|
|
font-family:
|
|
Roboto Mono Nerd Font,
|
|
monospace;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
:deep(.micron-preview) a {
|
|
pointer-events: none;
|
|
}
|
|
|
|
:deep(.micron-preview) p {
|
|
margin: 0.25rem 0;
|
|
}
|
|
|
|
:deep(.micron-preview) h1,
|
|
:deep(.micron-preview) h2,
|
|
:deep(.micron-preview) h3,
|
|
:deep(.micron-preview) h4 {
|
|
margin: 0.5rem 0 0.25rem 0;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|