Sunshine/src_assets/common/assets/web/featured.html
2026-01-29 10:16:37 -05:00

436 lines
14 KiB
HTML

<!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>