fix(web-ui): modernize UI (#4631)
This commit is contained in:
parent
76b3a8596f
commit
3ce39b36d0
19 changed files with 3529 additions and 456 deletions
436
src_assets/common/assets/web/featured.html
Normal file
436
src_assets/common/assets/web/featured.html
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="auto">
|
||||
|
||||
<head>
|
||||
<%- header %>
|
||||
</head>
|
||||
|
||||
<body id="app" v-cloak>
|
||||
<Navbar></Navbar>
|
||||
<div class="container">
|
||||
<div class="my-4">
|
||||
<h1>{{ $t('featured.title') }}</h1>
|
||||
<p>{{ $t('featured.description') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<div class="mb-4">
|
||||
<div class="btn-group" role="group" aria-label="Category filter">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
:class="{ active: selectedCategory === null }"
|
||||
@click="selectedCategory = null">
|
||||
{{ $t('_common.all') }}
|
||||
</button>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
:class="{ active: selectedCategory === category.id }"
|
||||
@click="selectedCategory = category.id">
|
||||
{{ $t(`featured.categories.${category.originalId}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{{ $t('_common.loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">{{ $t('_common.error') }}</h4>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div v-else class="row g-4">
|
||||
<div
|
||||
v-for="app in filteredApps"
|
||||
:key="app.id"
|
||||
class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card h-100 featured-app-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="featured-app-icon me-3">
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon + '?size=64'"
|
||||
:alt="app.name"
|
||||
class="rounded"
|
||||
@error="handleIconError($event)"
|
||||
/>
|
||||
<div v-else class="featured-app-icon-placeholder">
|
||||
<box :size="32" class="icon"></box>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<h5 class="card-title mb-1">{{ app.name }}</h5>
|
||||
<p class="text-muted small mb-0" v-if="app.tagline">{{ app.tagline }}</p>
|
||||
</div>
|
||||
<span v-if="app.official" class="badge bg-primary flex-shrink-0 ms-2">{{ $t('featured.official') }}</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted small mb-3">{{ app.description }}</p>
|
||||
|
||||
<!-- GitHub Metadata -->
|
||||
<div v-if="app.github" class="github-stats mb-3 d-flex gap-3 text-muted small">
|
||||
<span v-if="app.github.stars !== undefined" :title="$t('featured.github_stars')">
|
||||
<star :size="14" class="icon" fill="currentColor"></star>
|
||||
{{ formatNumber(app.github.stars) }}
|
||||
</span>
|
||||
<span v-if="app.github.forks !== undefined" :title="$t('featured.github_forks')">
|
||||
<git-fork :size="14" class="icon"></git-fork>
|
||||
{{ formatNumber(app.github.forks) }}
|
||||
</span>
|
||||
<span v-if="app.github.openIssues !== undefined" :title="$t('featured.github_issues')">
|
||||
<circle-dot :size="14" class="icon"></circle-dot>
|
||||
{{ formatNumber(app.github.openIssues) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Last Updated -->
|
||||
<div v-if="app.github && app.github.lastUpdated" class="text-muted small mb-3">
|
||||
<span :title="formatDate(app.github.lastUpdated).absolute">
|
||||
{{ $t('featured.last_updated') }}: {{ formatDate(app.github.lastUpdated).relative }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Screenshots Section -->
|
||||
<div v-if="app.screenshots && app.screenshots.length > 0" class="screenshots-container mb-3">
|
||||
<div class="screenshots-scroll">
|
||||
<img
|
||||
v-for="(screenshot, index) in app.screenshots"
|
||||
:key="index"
|
||||
:src="screenshot"
|
||||
:alt="app.name + ' screenshot ' + (index + 1)"
|
||||
class="screenshot-thumbnail"
|
||||
@click="openScreenshot(screenshot, app.screenshots)"
|
||||
@error="handleScreenshotError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Icons -->
|
||||
<div class="mb-3">
|
||||
<span class="badge bg-secondary me-1" v-for="platform in app.platforms" :key="platform">
|
||||
<monitor v-if="platform === 'windows'" :size="14" class="icon"></monitor>
|
||||
<monitor v-else-if="platform === 'macos'" :size="14" class="icon"></monitor>
|
||||
<monitor v-else-if="platform === 'linux'" :size="14" class="icon"></monitor>
|
||||
<smartphone v-else-if="platform === 'android'" :size="14" class="icon"></smartphone>
|
||||
<smartphone v-else-if="platform === 'ios'" :size="14" class="icon"></smartphone>
|
||||
<globe v-else-if="platform === 'web'" :size="14" class="icon"></globe>
|
||||
{{ platformName(platform) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a
|
||||
v-if="app.links && app.links.download"
|
||||
:href="app.links.download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-primary btn-sm flex-fill">
|
||||
<arrow-down-circle :size="16" class="icon"></arrow-down-circle>
|
||||
{{ $t('featured.get') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="app.links && app.links.documentation"
|
||||
:href="app.links.documentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
:title="$t('featured.documentation')">
|
||||
<external-link :size="16" class="icon"></external-link>
|
||||
{{ $t('featured.docs') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="app.links && app.links.website"
|
||||
:href="app.links.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
:title="$t('featured.website')">
|
||||
<external-link :size="16" class="icon"></external-link>
|
||||
</a>
|
||||
<a
|
||||
v-if="app.links && app.links.github"
|
||||
:href="app.links.github"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
:title="$t('featured.github')">
|
||||
<simple-icon icon="GitHub" :size="16" class="icon"></simple-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && !error && filteredApps.length === 0" class="text-center py-5">
|
||||
<p class="text-muted">{{ $t('featured.no_apps') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Modal -->
|
||||
<div
|
||||
v-if="selectedScreenshot"
|
||||
class="screenshot-modal"
|
||||
@click="closeScreenshot"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd">
|
||||
<div class="screenshot-modal-content">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close btn-close-white screenshot-close"
|
||||
@click="closeScreenshot"
|
||||
aria-label="Close"></button>
|
||||
|
||||
<!-- Previous Button -->
|
||||
<button
|
||||
v-if="currentAppScreenshots.length > 1"
|
||||
type="button"
|
||||
class="screenshot-nav screenshot-nav-prev"
|
||||
@click.stop="prevScreenshot"
|
||||
aria-label="Previous screenshot">
|
||||
<chevron-left :size="32"></chevron-left>
|
||||
</button>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
v-if="currentAppScreenshots.length > 1"
|
||||
type="button"
|
||||
class="screenshot-nav screenshot-nav-next"
|
||||
@click.stop="nextScreenshot"
|
||||
aria-label="Next screenshot">
|
||||
<chevron-right :size="32"></chevron-right>
|
||||
</button>
|
||||
|
||||
<!-- Screenshot Counter -->
|
||||
<div v-if="currentAppScreenshots.length > 1" class="screenshot-counter">
|
||||
{{ selectedScreenshotIndex + 1 }} / {{ currentAppScreenshots.length }}
|
||||
</div>
|
||||
|
||||
<img :src="selectedScreenshot" alt="Screenshot" @click.stop />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script type="module">
|
||||
import { createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import SimpleIcon from './SimpleIcon.vue'
|
||||
import { formatDistanceToNow, format } from 'date-fns'
|
||||
import {
|
||||
ArrowDownCircle,
|
||||
Box,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Globe,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Star,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const app = createApp({
|
||||
components: {
|
||||
Navbar,
|
||||
SimpleIcon,
|
||||
ArrowDownCircle,
|
||||
Box,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Globe,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Star,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
apps: [],
|
||||
categories: [],
|
||||
selectedCategory: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
selectedScreenshot: null,
|
||||
selectedScreenshotIndex: -1,
|
||||
currentAppScreenshots: [],
|
||||
touchStartX: 0,
|
||||
touchEndX: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredApps() {
|
||||
let filtered = this.selectedCategory
|
||||
? this.apps.filter(app => app.category === this.selectedCategory)
|
||||
: this.apps;
|
||||
|
||||
// Sort by official status first, then by GitHub stars
|
||||
return filtered.slice().sort((a, b) => {
|
||||
// Official apps first
|
||||
if (a.official && !b.official) return -1;
|
||||
if (!a.official && b.official) return 1;
|
||||
|
||||
// Then sort by GitHub stars (descending)
|
||||
const aStars = a.github?.stars || 0;
|
||||
const bStars = b.github?.stars || 0;
|
||||
return bStars - aStars;
|
||||
});
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadFeaturedApps();
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.handleKeydown);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('keydown', this.handleKeydown);
|
||||
},
|
||||
methods: {
|
||||
async loadFeaturedApps() {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// Fetch the app directory for Sunshine
|
||||
const indexUrl = 'https://app.lizardbyte.dev/app-directory/sunshine.json';
|
||||
|
||||
const response = await fetch(indexUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load featured apps');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.apps = data.apps || [];
|
||||
this.categories = data.categories || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading featured apps:', err);
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
'windows': 'Windows',
|
||||
'macos': 'macOS',
|
||||
'linux': 'Linux',
|
||||
'android': 'Android',
|
||||
'ios': 'iOS',
|
||||
'web': 'Web'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
handleIconError(event) {
|
||||
// Hide broken icon and show placeholder
|
||||
event.target.style.display = 'none';
|
||||
},
|
||||
openScreenshot(url, screenshots) {
|
||||
this.currentAppScreenshots = screenshots || [];
|
||||
this.selectedScreenshotIndex = this.currentAppScreenshots.indexOf(url);
|
||||
this.selectedScreenshot = url;
|
||||
},
|
||||
closeScreenshot() {
|
||||
this.selectedScreenshot = null;
|
||||
this.selectedScreenshotIndex = -1;
|
||||
this.currentAppScreenshots = [];
|
||||
},
|
||||
nextScreenshot() {
|
||||
if (this.currentAppScreenshots.length === 0) return;
|
||||
this.selectedScreenshotIndex = (this.selectedScreenshotIndex + 1) % this.currentAppScreenshots.length;
|
||||
this.selectedScreenshot = this.currentAppScreenshots[this.selectedScreenshotIndex];
|
||||
},
|
||||
prevScreenshot() {
|
||||
if (this.currentAppScreenshots.length === 0) return;
|
||||
this.selectedScreenshotIndex = (this.selectedScreenshotIndex - 1 + this.currentAppScreenshots.length) % this.currentAppScreenshots.length;
|
||||
this.selectedScreenshot = this.currentAppScreenshots[this.selectedScreenshotIndex];
|
||||
},
|
||||
handleKeydown(event) {
|
||||
if (!this.selectedScreenshot) return;
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.nextScreenshot();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
this.prevScreenshot();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.closeScreenshot();
|
||||
}
|
||||
},
|
||||
handleTouchStart(event) {
|
||||
this.touchStartX = event.changedTouches[0].screenX;
|
||||
},
|
||||
handleTouchEnd(event) {
|
||||
this.touchEndX = event.changedTouches[0].screenX;
|
||||
this.handleSwipe();
|
||||
},
|
||||
handleSwipe() {
|
||||
const swipeThreshold = 50;
|
||||
const diff = this.touchStartX - this.touchEndX;
|
||||
|
||||
if (Math.abs(diff) > swipeThreshold) {
|
||||
if (diff > 0) {
|
||||
// Swiped left, show next
|
||||
this.nextScreenshot();
|
||||
} else {
|
||||
// Swiped right, show previous
|
||||
this.prevScreenshot();
|
||||
}
|
||||
}
|
||||
},
|
||||
handleScreenshotError(event) {
|
||||
event.target.style.display = 'none';
|
||||
},
|
||||
formatNumber(num) {
|
||||
if (num === undefined || num === null) return '0';
|
||||
|
||||
const absNum = Math.abs(num);
|
||||
|
||||
if (absNum >= 1000000000) {
|
||||
return (num / 1000000000).toFixed(1) + 'B';
|
||||
} else if (absNum >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (absNum >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
|
||||
return num.toString();
|
||||
},
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return { relative: '', absolute: '' };
|
||||
|
||||
const date = new Date(dateString);
|
||||
|
||||
// Format absolute date like GitHub: "Jan 28, 2026, 1:52 PM UTC"
|
||||
const absolute = format(date, 'MMM d, yyyy, h:mm a zzz');
|
||||
|
||||
// Use date-fns to get relative time like "2 days ago"
|
||||
const relative = formatDistanceToNow(date, { addSuffix: true });
|
||||
|
||||
return { relative, absolute };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
initApp(app);
|
||||
</script>
|
||||
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue