mirror of
https://github.com/hugo-fixit/FixIt.git
synced 2026-08-24 15:28:57 +00:00
refactor(assets): PWA improvements and app config snake_case migration (#790)
- Service worker: versioned cache name, navigation preload, stale-while-revalidate for images
- New PWAModule extracted from MiscModule with async registration and update detection
- SW update notification toast in Hugo template with i18n support (16 languages)
- Web app manifest template (site.webmanifest) generated from [params.app] config
- [params.app] keys migrated to snake_case: name, short_name, pwa, no_favicon, svg_favicon, mask_color, tile_color, theme_color
- enablePWA moved from [params] to [params.app].pwa
- theme_color no longer accepts single-value, only {light, dark} map
- EventBus: new fixit:sw-update event
- SCSS: new _sw-toast.scss widget for update notification
This commit is contained in:
@@ -7,6 +7,7 @@ export interface FixItEventMap {
|
||||
'fixit:partial-decrypted': { target: Element }
|
||||
'fixit:re-encrypt': void
|
||||
'fixit:code-tab-sync': { lang: string, source: HTMLElement }
|
||||
'fixit:sw-update': void
|
||||
}
|
||||
|
||||
/** Document event map augmented with FixIt custom events. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EncryptionModule } from '../modules/encryption'
|
||||
import { EventsModule } from '../modules/events'
|
||||
import { MenuModule } from '../modules/menu'
|
||||
import { MiscModule } from '../modules/misc'
|
||||
import { PWAModule } from '../modules/pwa'
|
||||
import { SearchModule } from '../modules/search'
|
||||
import { ThemeModule } from '../modules/theme'
|
||||
import { TocModule } from '../modules/toc'
|
||||
@@ -24,6 +25,7 @@ export class PublicAPI implements FixItPublicAPI {
|
||||
readonly menu
|
||||
readonly search
|
||||
readonly enc
|
||||
readonly pwa
|
||||
readonly misc
|
||||
readonly content
|
||||
readonly events
|
||||
@@ -38,6 +40,7 @@ export class PublicAPI implements FixItPublicAPI {
|
||||
this.menu = new MenuModule(this.core)
|
||||
this.search = new SearchModule(this.core)
|
||||
this.enc = new EncryptionModule(this.core)
|
||||
this.pwa = new PWAModule(this.core)
|
||||
this.misc = new MiscModule(this.core)
|
||||
this.content = new ContentModule(this.core, this.code)
|
||||
this.events = new EventsModule(this.core, this.toc, this.code)
|
||||
|
||||
@@ -67,7 +67,6 @@ export interface ContentService {
|
||||
// ─── MiscService ───
|
||||
export interface MiscService {
|
||||
initSiteTime: () => void
|
||||
initServiceWorker: () => void
|
||||
initAutoMark: () => void
|
||||
initReward: () => void
|
||||
initPostChatUser: () => void
|
||||
@@ -75,6 +74,11 @@ export interface MiscService {
|
||||
setup: () => void
|
||||
}
|
||||
|
||||
// ─── PWAService ───
|
||||
export interface PWAService {
|
||||
setup: () => void
|
||||
}
|
||||
|
||||
// ─── EventsService ───
|
||||
export interface EventsService {
|
||||
onScroll: () => void
|
||||
@@ -98,6 +102,7 @@ export interface FixItPublicAPI {
|
||||
readonly menu: MenuService
|
||||
readonly search: SearchService
|
||||
readonly enc: EncryptionService
|
||||
readonly pwa: PWAService
|
||||
readonly misc: MiscService
|
||||
readonly content: ContentService
|
||||
readonly events: EventsService
|
||||
|
||||
@@ -28,6 +28,7 @@ function bootstrap(): void {
|
||||
window.fixit.search.setup()
|
||||
window.fixit.content.setup()
|
||||
window.fixit.enc.setup()
|
||||
window.fixit.pwa.setup()
|
||||
window.fixit.misc.setup()
|
||||
window.fixit.events.setup()
|
||||
}
|
||||
|
||||
@@ -49,25 +49,6 @@ export class MiscModule implements MiscService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the service worker for PWA support. */
|
||||
initServiceWorker() {
|
||||
if (this.core.config.PWA?.enable && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register(this.core.config.PWA.serviceWorkerURL)
|
||||
.then((_registration) => {
|
||||
// console.log('Service Worker Registered');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('error: ', error)
|
||||
})
|
||||
navigator.serviceWorker
|
||||
.ready
|
||||
.then((_registration) => {
|
||||
// console.log('Service Worker Ready');
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Save and restore scroll position as an automatic bookmark. */
|
||||
initAutoMark() {
|
||||
if (!this.core.config.autoBookmark)
|
||||
@@ -144,7 +125,6 @@ export class MiscModule implements MiscService {
|
||||
/** Initialize all miscellaneous features. */
|
||||
setup() {
|
||||
this.initSiteTime()
|
||||
this.initServiceWorker()
|
||||
this.initAutoMark()
|
||||
this.initReward()
|
||||
this.initPostChatUser()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CoreService, PWAService } from '../core/tokens'
|
||||
import { eventBus } from '../core/event-bus'
|
||||
|
||||
/**
|
||||
* PWA module — service worker registration and update notification.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Register service worker with update detection.
|
||||
* - Periodically check for service worker updates.
|
||||
* - Show update notification toast and handle refresh.
|
||||
*/
|
||||
export class PWAModule implements PWAService {
|
||||
constructor(private readonly core: CoreService) {}
|
||||
|
||||
/** Register the service worker with update detection and notification binding. */
|
||||
async setup() {
|
||||
const pwa = this.core.config.PWA
|
||||
if (!pwa?.enable || !('serviceWorker' in navigator))
|
||||
return
|
||||
|
||||
const registration = await navigator.serviceWorker
|
||||
.register(pwa.serviceWorkerURL)
|
||||
.catch((error: unknown) => {
|
||||
console.error('Service Worker registration failed:', error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!registration)
|
||||
return
|
||||
|
||||
// Check for updates periodically (every hour)
|
||||
setInterval(() => registration.update(), 3600000)
|
||||
|
||||
// Listen for new service worker installing
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
if (!newWorker)
|
||||
return
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
eventBus.emit('fixit:sw-update')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Reload when a new service worker takes control
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
this.bindUpdateNotification(registration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the template-rendered update notification toast.
|
||||
* Shows the toast on `fixit:sw-update` and wires the refresh button to skip waiting.
|
||||
*/
|
||||
private bindUpdateNotification(registration: ServiceWorkerRegistration) {
|
||||
const toast = document.querySelector<HTMLElement>('.sw-update-notification')
|
||||
if (!toast)
|
||||
return
|
||||
|
||||
eventBus.on('fixit:sw-update', () => {
|
||||
toast.querySelector('.sw-update-btn')!.addEventListener('click', () => {
|
||||
registration.waiting?.postMessage({ type: 'SKIP_WAITING' })
|
||||
toast.classList.remove('visible')
|
||||
}, { once: true })
|
||||
requestAnimationFrame(() => toast.classList.add('visible'))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
/**
|
||||
* Service Worker
|
||||
* @description Two-strategy caching with the native Cache API:
|
||||
* @description Three-strategy caching with the native Cache API:
|
||||
* - Static assets (fingerprinted CSS/JS): cache-first, immutable
|
||||
* - Images: stale-while-revalidate
|
||||
* - HTML pages: network-first, fallback to cache when offline
|
||||
* Asset URLs are injected at build time via Hugo's ExecuteAsTemplate,
|
||||
* ensuring they match fingerprinted paths when fingerprint is enabled.
|
||||
@@ -12,7 +13,7 @@
|
||||
|
||||
/* ========== Constants ========== */
|
||||
|
||||
const CACHE_NAME = 'fixit-v1'
|
||||
const CACHE_NAME = 'fixit-{{ .version }}'
|
||||
|
||||
/** URLs to pre-cache during the install event (injected by Hugo). */
|
||||
const PRECACHE_URLS = [
|
||||
@@ -30,7 +31,10 @@ const NOT_FOUND_PAGE = '{{ .relURL }}404.html'
|
||||
const MAX_HTML_CACHE_ENTRIES = 100
|
||||
|
||||
/** Static asset extensions — these are fingerprinted by Hugo and safe to cache forever. */
|
||||
const STATIC_EXTENSIONS = ['css', 'js', 'woff2', 'woff', 'ttf', 'eot', 'svg', 'webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'ico']
|
||||
const STATIC_EXTENSIONS = ['css', 'js', 'woff2', 'woff', 'ttf', 'eot', 'svg']
|
||||
|
||||
/** Image extensions — use stale-while-revalidate strategy. */
|
||||
const IMAGE_EXTENSIONS = ['webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'ico']
|
||||
|
||||
/* ========== Helpers ========== */
|
||||
|
||||
@@ -52,6 +56,20 @@ function isStaticAsset(url) {
|
||||
return STATIC_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a same-origin image asset.
|
||||
*/
|
||||
function isImageAsset(url) {
|
||||
if (!url.startsWith(self.location.origin))
|
||||
return false
|
||||
const pathname = new URL(url).pathname
|
||||
const dotIndex = pathname.lastIndexOf('.')
|
||||
if (dotIndex === -1)
|
||||
return false
|
||||
const ext = pathname.slice(dotIndex + 1).toLowerCase()
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict oldest HTML cache entries when the limit is exceeded.
|
||||
* Operates on cache insertion order (Cache API preserves insertion order for keys()).
|
||||
@@ -62,7 +80,7 @@ async function evictOldEntries() {
|
||||
const keys = await cache.keys()
|
||||
// PRECACHE_URLS contains relative paths from Hugo; resolve them to absolute URLs for comparison.
|
||||
const precacheSet = new Set(PRECACHE_URLS.map(url => new URL(url, self.location.origin).href))
|
||||
const htmlKeys = keys.filter(req => !isStaticAsset(req.url) && !precacheSet.has(req.url))
|
||||
const htmlKeys = keys.filter(req => !isStaticAsset(req.url) && !isImageAsset(req.url) && !precacheSet.has(req.url))
|
||||
const excess = htmlKeys.length - MAX_HTML_CACHE_ENTRIES
|
||||
if (excess > 0) {
|
||||
await Promise.all(htmlKeys.slice(0, excess).map(req => cache.delete(req)))
|
||||
@@ -82,13 +100,17 @@ self.addEventListener('install', (event) => {
|
||||
)
|
||||
})
|
||||
|
||||
/** Clean up old caches, evict stale HTML entries, and take control immediately. */
|
||||
/** Clean up old caches, evict stale HTML entries, enable navigation preload, and take control. */
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)),
|
||||
))
|
||||
Promise.all([
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)),
|
||||
)),
|
||||
// Enable navigation preload for faster page loads
|
||||
self.registration.navigationPreload?.enable(),
|
||||
])
|
||||
.then(() => evictOldEntries())
|
||||
.then(() => self.clients.claim()),
|
||||
)
|
||||
@@ -104,11 +126,15 @@ self.addEventListener('fetch', (event) => {
|
||||
if (request.method !== 'GET' || !request.url.startsWith(self.location.origin))
|
||||
return
|
||||
|
||||
event.respondWith(
|
||||
isStaticAsset(request.url)
|
||||
? cacheFirst(request)
|
||||
: networkFirst(request),
|
||||
)
|
||||
if (isStaticAsset(request.url)) {
|
||||
event.respondWith(cacheFirst(request))
|
||||
}
|
||||
else if (isImageAsset(request.url)) {
|
||||
event.respondWith(staleWhileRevalidate(request))
|
||||
}
|
||||
else {
|
||||
event.respondWith(networkFirst(request, event))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -134,13 +160,34 @@ async function cacheFirst(request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate strategy for images.
|
||||
* Returns cached version immediately while fetching an update in the background.
|
||||
*/
|
||||
async function staleWhileRevalidate(request) {
|
||||
const cache = await cachePromise
|
||||
const cached = await cache.match(request)
|
||||
|
||||
const fetchPromise = fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone())
|
||||
}
|
||||
return response
|
||||
}).catch(() => cached)
|
||||
|
||||
return cached || fetchPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first strategy for HTML pages.
|
||||
* Uses navigation preload when available, falls back to network fetch.
|
||||
* Always tries the network for fresh content, falls back to cache on failure.
|
||||
*/
|
||||
async function networkFirst(request) {
|
||||
async function networkFirst(request, event) {
|
||||
try {
|
||||
const response = await fetch(request)
|
||||
// Use preloaded response if available (enabled in activate handler)
|
||||
const preloadResponse = await event?.preloadResponse
|
||||
const response = preloadResponse || await fetch(request)
|
||||
if (response.ok) {
|
||||
const cache = await cachePromise
|
||||
cache.put(request, response.clone())
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/// ==========================================================================
|
||||
/// Custom Styles
|
||||
///
|
||||
/// Copy this file to your project's assets/scss/ directory to activate it.
|
||||
/// Copy this file to your project's assets/scss/ directory
|
||||
/// and rename it to custom.scss to activate it.
|
||||
/// See: https://fixit.lruihao.cn/documentation/advanced/#style-customization
|
||||
/// ==========================================================================
|
||||
// @use "core/mixins" as *;
|
||||
@use "core/mixins" as *;
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// Custom Fonts
|
||||
// Configure font families via [params.appearance] in hugo.toml:
|
||||
@@ -12,25 +13,25 @@
|
||||
// code_font_family = "Fira Mono, monospace"
|
||||
// Import custom font CSS below (skip if using system fonts).
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// @import url('https://chinese-fonts-cdn.deno.dev/packages/lxgwwenkai/dist/LXGWWenKai-Regular/result.css');
|
||||
// @import url('https://fonts.googleapis.com/css?family=Fira+Mono:400,700&display=swap&subset=latin-ext');
|
||||
@import url('https://chinese-fonts-cdn.deno.dev/packages/lxgwwenkai/dist/LXGWWenKai-Regular/result.css');
|
||||
@import url('https://fonts.googleapis.com/css?family=Fira+Mono:400,700&display=swap&subset=latin-ext');
|
||||
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// Custom Page Width
|
||||
// Set pageStyle="custom" in the <body> element to apply.
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// @include page-style('custom') {
|
||||
// @include media('xl') {
|
||||
// width: ROUND(70%, 2px);
|
||||
// max-width: 1600px;
|
||||
// }
|
||||
// @include media('lg') {
|
||||
// width: ROUND(60%, 2px);
|
||||
// }
|
||||
// @include media('md') {
|
||||
// width: ROUND(56%, 2px);
|
||||
// }
|
||||
// }
|
||||
@include page-style('custom') {
|
||||
@include media('xl') {
|
||||
width: ROUND(70%, 2px);
|
||||
max-width: 1600px;
|
||||
}
|
||||
@include media('lg') {
|
||||
width: ROUND(60%, 2px);
|
||||
}
|
||||
@include media('md') {
|
||||
width: ROUND(56%, 2px);
|
||||
}
|
||||
}
|
||||
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// Custom Admonitions
|
||||
@@ -39,9 +40,9 @@
|
||||
// ban = "fa-solid fa-ban"
|
||||
// Then use in content: {{</* admonition ban */>}} or > [!ban]
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// .admonition {
|
||||
// @include admonition(ban, #ff3d00, rgba(255, 61, 0, 0.1));
|
||||
// }
|
||||
.admonition {
|
||||
@include admonition(ban, #ff3d00, rgba(255, 61, 0, 0.1));
|
||||
}
|
||||
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// Custom Task List Style
|
||||
@@ -49,7 +50,7 @@
|
||||
// [params.taskList]
|
||||
// tip = "fa-regular fa-lightbulb"
|
||||
// ————————————————————————————————————————————————————————————
|
||||
// li[data-task='tip'] {
|
||||
// @include task-icon(#EA9E36);
|
||||
// @include task-text(#9974F7);
|
||||
// }
|
||||
li[data-task='tip'] {
|
||||
@include task-icon(#EA9E36);
|
||||
@include task-text(#9974F7);
|
||||
}
|
||||
@@ -8,3 +8,4 @@
|
||||
@use "noscript-warning";
|
||||
@use "reading-progress";
|
||||
@use "scrollbar";
|
||||
@use "sw-toast";
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
@use "core/functions" as *;
|
||||
@use "core/mixins" as *;
|
||||
|
||||
.sw-update-notification {
|
||||
position: fixed;
|
||||
inset-inline-end: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background-color: fi-var(global-background-color);
|
||||
border: 1px solid fi-var(global-border-color);
|
||||
border-radius: 0.5rem;
|
||||
color: fi-var(global-font-color);
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.15);
|
||||
translate: 0 1rem;
|
||||
opacity: 0;
|
||||
transition:
|
||||
translate 0.3s,
|
||||
opacity 0.3s;
|
||||
@include z-index(fixed);
|
||||
|
||||
&.visible {
|
||||
translate: 0 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
&.visible {
|
||||
translate: 0 1rem;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@include media('print') {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sw-update-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-update-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sw-update-text {
|
||||
margin: 0.125rem 0 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sw-update-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
background-color: fi-var(primary);
|
||||
color: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
@include border-radius;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -376,13 +376,20 @@ isPlainText = true
|
||||
isHTML = false
|
||||
permalinkable = true
|
||||
|
||||
# FixIt 1.0.0 | NEW Options to make output site.webmanifest file
|
||||
[outputFormats.manifest]
|
||||
baseName = "site"
|
||||
mediaType = "application/manifest+json"
|
||||
isPlainText = true
|
||||
isHTML = false
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# Output Configuration
|
||||
# See: https://gohugo.io/configuration/outputs/
|
||||
# -------------------------------------------------------------------------------------
|
||||
|
||||
# options to make hugo output files, the optional values are below:
|
||||
# home = ["html", "rss", "archives", "offline", "link", "search", "readme", "baidu_urls"]
|
||||
# home = ["html", "rss", "archives", "search", "offline", "manifest", "link", "readme", "baidu_urls"]
|
||||
# page = ["html", "markdown"]
|
||||
# section = ["html", "rss"]
|
||||
# taxonomy = ["html"]
|
||||
@@ -393,9 +400,10 @@ home = [
|
||||
"html",
|
||||
"rss",
|
||||
"archives",
|
||||
"search",
|
||||
"offline",
|
||||
"link",
|
||||
"search"
|
||||
"manifest",
|
||||
"link"
|
||||
]
|
||||
page = [
|
||||
"html",
|
||||
@@ -443,8 +451,6 @@ fingerprint = ""
|
||||
dateFormat = "2006-01-02"
|
||||
# website images for Open Graph and Twitter Cards
|
||||
images = []
|
||||
# FixIt 0.2.12 | NEW enable PWA
|
||||
enablePWA = false
|
||||
# FixIt 0.3.13 | NEW whether to capitalize titles
|
||||
capitalizeTitles = true
|
||||
# FixIt 0.3.0 | NEW whether to add site title to the title of every page
|
||||
@@ -486,24 +492,45 @@ dir = "content"
|
||||
# available template params: {title} {URL} {sourceURL}
|
||||
issueTpl = "title=[BUG]%20{title}&body=|Field|Value|%0A|-|-|%0A|Title|{title}|%0A|URL|{URL}|%0A|Filename|{sourceURL}|"
|
||||
|
||||
# App icon config
|
||||
# FixIt 1.0.0 | CHANGED App and PWA config
|
||||
[params.app]
|
||||
# optional site title override for the app when added to an iOS home screen or Android launcher
|
||||
title = "FixIt"
|
||||
# whether to enable PWA support
|
||||
pwa = false
|
||||
# app name used for home screen and manifest (falls back to site title)
|
||||
name = ""
|
||||
# optional short name for the manifest (falls back to name)
|
||||
short_name = ""
|
||||
# whether to omit favicon resource links
|
||||
noFavicon = false
|
||||
no_favicon = false
|
||||
# modern SVG favicon to use in place of older style .png and .ico files
|
||||
svgFavicon = ""
|
||||
svg_favicon = ""
|
||||
# Safari mask icon color
|
||||
iconColor = "#5bbad5"
|
||||
mask_color = "#5bbad5"
|
||||
# Windows v8-10 tile color
|
||||
tileColor = "#da532c"
|
||||
tile_color = "#da532c"
|
||||
|
||||
# FixIt 0.2.12 | CHANGED Android browser theme color
|
||||
[params.app.themeColor]
|
||||
# Android browser theme color
|
||||
[params.app.theme_color]
|
||||
light = "#f6f8fa"
|
||||
dark = "#151b23"
|
||||
|
||||
# Web app manifest icons (users should provide their own icon files)
|
||||
[[params.app.icons]]
|
||||
src = "/apple-touch-icon.png"
|
||||
sizes = "180x180"
|
||||
type = "image/png"
|
||||
purpose = "any maskable"
|
||||
|
||||
[[params.app.icons]]
|
||||
src = "/android-chrome-192x192.png"
|
||||
sizes = "192x192"
|
||||
type = "image/png"
|
||||
|
||||
[[params.app.icons]]
|
||||
src = "/android-chrome-512x512.png"
|
||||
sizes = "512x512"
|
||||
type = "image/png"
|
||||
|
||||
# Search config
|
||||
[params.search]
|
||||
enable = true
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Abbrechen"
|
||||
navigate = "Navigieren"
|
||||
select = "Auswählen"
|
||||
close = "Schließen"
|
||||
refresh = "Aktualisieren"
|
||||
noResultsFound = "Keine Ergebnisse gefunden"
|
||||
copyToClipboard = "In Zwischenablage kopieren"
|
||||
copyText = "Kopieren"
|
||||
@@ -186,6 +187,12 @@ other = "Offline"
|
||||
other = "Sie sind nicht mit dem Internet verbunden, es stehen nur zwischengespeicherte Seiten zur Verfügung."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Update verfügbar"
|
||||
updateText = "Eine neue Version dieser Website ist verfügbar."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Hinweis zur Weiterleitung"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Cancel"
|
||||
navigate = "Navigate"
|
||||
select = "Select"
|
||||
close = "Close"
|
||||
refresh = "Refresh"
|
||||
noResultsFound = "No results found"
|
||||
copyToClipboard = "Copy to clipboard"
|
||||
copyText = "Copy"
|
||||
@@ -186,6 +187,12 @@ other = "Offline"
|
||||
other = "You are not connected to the Internet, only cached pages will be available."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Update Available"
|
||||
updateText = "A new version of this site is available."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Redirection Notice"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Cancelar"
|
||||
navigate = "Navegar"
|
||||
select = "Seleccionar"
|
||||
close = "Cerrar"
|
||||
refresh = "Actualizar"
|
||||
noResultsFound = "No se encontraron resultados"
|
||||
copyToClipboard = "Copiar al portapapeles"
|
||||
copyText = "Copiar"
|
||||
@@ -186,6 +187,12 @@ other = "desconectado"
|
||||
other = "No está conectado a Internet, solo estarán disponibles las páginas almacenadas en caché."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Actualización disponible"
|
||||
updateText = "Hay una nueva versión de este sitio disponible."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Aviso de redirección"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Annuler"
|
||||
navigate = "Naviguer"
|
||||
select = "Sélectionner"
|
||||
close = "Fermer"
|
||||
refresh = "Actualiser"
|
||||
noResultsFound = "Aucun résultat trouvé"
|
||||
copyToClipboard = "Copier dans le presse-papiers"
|
||||
copyText = "Copier"
|
||||
@@ -186,6 +187,12 @@ other = "Hors ligne"
|
||||
other = "Vous n'êtes pas connecté à Internet, seules les pages mises en cache seront disponibles."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Mise à jour disponible"
|
||||
updateText = "Une nouvelle version de ce site est disponible."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Avis de redirection"
|
||||
|
||||
@@ -100,6 +100,7 @@ cancel = "रद्द करें"
|
||||
navigate = "नेविगेट"
|
||||
select = "चुनें"
|
||||
close = "बंद करें"
|
||||
refresh = "रिफ़्रेश"
|
||||
noResultsFound = "कोई परिणाम नहीं मिला"
|
||||
copyToClipboard = "क्लिपबोर्ड पर कॉपी करें"
|
||||
copyText = "कॉपी करें"
|
||||
@@ -187,6 +188,12 @@ other = "ऑफलाइन"
|
||||
other = "आप इंटरनेट से कनेक्ट नहीं हैं, केवल कैश्ड पेज ही उपलब्ध होंगे।"
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "अपडेट उपलब्ध"
|
||||
updateText = "इस साइट का नया संस्करण उपलब्ध है।"
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "रीडायरेक्शन सूचना"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Annulla"
|
||||
navigate = "Naviga"
|
||||
select = "Seleziona"
|
||||
close = "Chiudi"
|
||||
refresh = "Aggiorna"
|
||||
noResultsFound = "Nessun risultato trovato"
|
||||
copyToClipboard = "Copia negli appunti"
|
||||
copyText = "Copia"
|
||||
@@ -186,6 +187,12 @@ other = "disconnesso"
|
||||
other = "Non sei connesso a Internet, saranno disponibili solo le pagine memorizzate nella cache."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Aggiornamento disponibile"
|
||||
updateText = "È disponibile una nuova versione di questo sito."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Avviso di reindirizzamento"
|
||||
|
||||
@@ -97,6 +97,7 @@ cancel = "キャンセル"
|
||||
navigate = "移動"
|
||||
select = "選択"
|
||||
close = "閉じる"
|
||||
refresh = "更新"
|
||||
noResultsFound = "結果が見つかりません"
|
||||
copyToClipboard = "クリップボードにコピー"
|
||||
copyText = "コピー"
|
||||
@@ -182,6 +183,12 @@ other = "オフライン"
|
||||
other = "インターネットに接続されていません。キャッシュされたページのみが利用可能です。"
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "アップデートがあります"
|
||||
updateText = "このサイトの新しいバージョンが利用可能です。"
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "リダイレクト通知"
|
||||
|
||||
@@ -97,6 +97,7 @@ cancel = "취소"
|
||||
navigate = "탐색"
|
||||
select = "선택"
|
||||
close = "닫기"
|
||||
refresh = "새로고침"
|
||||
noResultsFound = "결과를 찾을 수 없습니다"
|
||||
copyToClipboard = "클립보드에 복사"
|
||||
copyText = "복사"
|
||||
@@ -182,6 +183,12 @@ other = "오프라인"
|
||||
other = "인터넷에 연결되어 있지 않으며, 캐시된 페이지만 사용할 수 있습니다."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "업데이트 가능"
|
||||
updateText = "이 사이트의 새 버전을 사용할 수 있습니다."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "리디렉션 안내"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Anuluj"
|
||||
navigate = "Nawiguj"
|
||||
select = "Wybierz"
|
||||
close = "Zamknij"
|
||||
refresh = "Odśwież"
|
||||
noResultsFound = "Nie znaleziono wyników"
|
||||
copyToClipboard = "Skopiuj do schowka"
|
||||
copyText = "Kopiuj"
|
||||
@@ -186,6 +187,12 @@ other = "Offline"
|
||||
other = "Nie masz połączenia z Internetem, dostępne będą tylko strony z pamięci podręcznej."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Dostępna aktualizacja"
|
||||
updateText = "Dostępna jest nowa wersja tej strony."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Powiadomienie o przekierowaniu"
|
||||
|
||||
@@ -100,6 +100,7 @@ cancel = "Cancelar"
|
||||
navigate = "Navegar"
|
||||
select = "Selecionar"
|
||||
close = "Fechar"
|
||||
refresh = "Atualizar"
|
||||
noResultsFound = "Nenhum resultado encontrado"
|
||||
copyToClipboard = "Copiar para a área de transferência"
|
||||
copyText = "Copiar"
|
||||
@@ -187,6 +188,12 @@ other = "Offline"
|
||||
other = "Você não está conectado à Internet, apenas as páginas em cache estarão disponíveis."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Atualização disponível"
|
||||
updateText = "Uma nova versão deste site está disponível."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Aviso de redirecionamento"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Anulare"
|
||||
navigate = "Navigare"
|
||||
select = "Selectare"
|
||||
close = "Închide"
|
||||
refresh = "Reîmprospătare"
|
||||
noResultsFound = "Nici un rezultat gasit"
|
||||
copyToClipboard = "Copiați în clipboard"
|
||||
copyText = "Copiază"
|
||||
@@ -186,6 +187,12 @@ other = "Deconectat"
|
||||
other = "Nu sunteți conectat la Internet, vor fi disponibile doar paginile stocate în cache."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Actualizare disponibilă"
|
||||
updateText = "O nouă versiune a acestui site este disponibilă."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Notificare de redirecționare"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Отменить"
|
||||
navigate = "Навигация"
|
||||
select = "Выбрать"
|
||||
close = "Закрыть"
|
||||
refresh = "Обновить"
|
||||
noResultsFound = "Результаты не найдены"
|
||||
copyToClipboard = "Копировать в буфер обмена"
|
||||
copyText = "Копировать"
|
||||
@@ -186,6 +187,12 @@ other = "Не в сети"
|
||||
other = "Вы не подключены к интернету, будут доступны только кешированные страницы."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Доступно обновление"
|
||||
updateText = "Доступна новая версия этого сайта."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Уведомление о перенаправлении"
|
||||
|
||||
@@ -99,6 +99,7 @@ cancel = "Поништи"
|
||||
navigate = "Навигација"
|
||||
select = "Изабери"
|
||||
close = "Затвори"
|
||||
refresh = "Освежи"
|
||||
noResultsFound = "Резултати нису пронађени"
|
||||
copyToClipboard = "Копирај на радну таблу"
|
||||
copyText = "Копирај"
|
||||
@@ -186,6 +187,12 @@ other = "Оффлине"
|
||||
other = "Нисте повезани на Интернет, биће доступне само кеширане странице."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Ажурирање доступно"
|
||||
updateText = "Нова верзија овог сајта је доступна."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Обавештење о преусмеравању"
|
||||
|
||||
@@ -98,6 +98,7 @@ cancel = "Huỷ"
|
||||
navigate = "Điều hướng"
|
||||
select = "Chọn"
|
||||
close = "Đóng"
|
||||
refresh = "Làm mới"
|
||||
noResultsFound = "Không tìm thấy kết quả"
|
||||
copyToClipboard = "Sao chép vào bộ nhớ tạm"
|
||||
copyText = "Sao chép"
|
||||
@@ -185,6 +186,12 @@ other = "ngoại tuyến"
|
||||
other = "Bạn chưa kết nối với Internet, chỉ các trang được lưu trong bộ nhớ cache sẽ khả dụng."
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "Có bản cập nhật"
|
||||
updateText = "Một phiên bản mới của trang web này đã sẵn sàng."
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "Thông báo chuyển hướng"
|
||||
|
||||
@@ -97,6 +97,7 @@ cancel = "取消"
|
||||
navigate = "切换"
|
||||
select = "选择"
|
||||
close = "关闭"
|
||||
refresh = "刷新"
|
||||
noResultsFound = "没有找到结果"
|
||||
copyToClipboard = "复制到剪贴板"
|
||||
copyText = "复制"
|
||||
@@ -182,6 +183,12 @@ other = "离线"
|
||||
other = "你没有连接到 Internet,只有缓存的页面可用。"
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "发现新版本"
|
||||
updateText = "当前站点有新版本可用。"
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "跳转提示"
|
||||
|
||||
@@ -97,6 +97,7 @@ cancel = "取消"
|
||||
navigate = "切換"
|
||||
select = "選擇"
|
||||
close = "關閉"
|
||||
refresh = "重新整理"
|
||||
noResultsFound = "沒有找到結果"
|
||||
copyToClipboard = "複製到剪貼板"
|
||||
copyText = "複製"
|
||||
@@ -182,6 +183,12 @@ other = "離線"
|
||||
other = "你沒有連接到 Internet,只有緩存的頁面可用。"
|
||||
# === Offline ===
|
||||
|
||||
# === Service Worker ===
|
||||
[serviceWorker]
|
||||
updateTitle = "發現新版本"
|
||||
updateText = "當前站點有新版本可用。"
|
||||
# === Service Worker ===
|
||||
|
||||
# === Link Redirection ===
|
||||
[linkRedirection]
|
||||
title = "跳轉提示"
|
||||
|
||||
@@ -288,17 +288,17 @@
|
||||
{{- end -}}
|
||||
|
||||
{{- /* PWA */ -}}
|
||||
{{- if not hugo.IsServer | and .Site.Params.enablePWA | and hugo.IsProduction -}}
|
||||
{{- if not hugo.IsServer | and hugo.IsProduction | and .Site.Params.app.pwa -}}
|
||||
{{- $offlineURL := "" -}}
|
||||
{{- with .Site.Home.OutputFormats.Get "offline" -}}
|
||||
{{- $offlineURL = .RelPermalink -}}
|
||||
{{- end -}}
|
||||
{{- $mainCSS := .Site.Store.Get "mainCSS" -}}
|
||||
{{- $mainJS := .Site.Store.Get "mainJS" -}}
|
||||
{{- $swBuildCtx := dict "mainCSSURL" $mainCSS.RelPermalink "mainJSURL" $mainJS.RelPermalink "offlineURL" $offlineURL "relURL" (relURL "") "Page" . -}}
|
||||
{{- $swBuildCtx := dict "mainCSSURL" $mainCSS.RelPermalink "mainJSURL" $mainJS.RelPermalink "offlineURL" $offlineURL "relURL" (relURL "") "version" (hugo.Store.Get "version") "Page" . -}}
|
||||
{{- $serviceWorker := resources.Get "js/service-worker.template.js" | resources.ExecuteAsTemplate "js/service-worker.template.js" $swBuildCtx -}}
|
||||
{{- $serviceWorker = dict "Resource" $serviceWorker "Build" (dict "targetPath" "sw.js") "Fingerprint" $fingerprint | partial "function/js-build.html" -}}
|
||||
{{- $config = dict "PWA" (dict "enable" .Site.Params.enablePWA "serviceWorkerURL" $serviceWorker.RelPermalink) | merge $config -}}
|
||||
{{- $config = dict "PWA" (dict "enable" .Site.Params.app.pwa "serviceWorkerURL" $serviceWorker.RelPermalink) | merge $config -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /* Auto Bookmark */ -}}
|
||||
|
||||
@@ -38,18 +38,14 @@
|
||||
|
||||
{{- partial "plugin/pagefind-metadata.html" . -}}
|
||||
|
||||
<meta name="application-name" content="{{ .Site.Params.app.title | default .Site.Title }}">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ .Site.Params.app.title | default .Site.Title }}">
|
||||
<meta name="application-name" content="{{ .Site.Params.app.name | default .Site.Title }}">
|
||||
<meta name="apple-mobile-web-app-title" content="{{ .Site.Params.app.name | default .Site.Title }}">
|
||||
|
||||
{{- with .Site.Params.app.themeColor -}}
|
||||
{{- $color := . -}}
|
||||
{{- if ne (len $color) 2 -}}
|
||||
{{- $color = dict "light" . "dark" . -}}
|
||||
{{- end -}}
|
||||
<meta name="theme-color" data-light="{{ $color.light }}" data-dark="{{ $color.dark }}" content="{{ $color.light }}">
|
||||
{{- with .Site.Params.app.theme_color -}}
|
||||
<meta name="theme-color" data-light="{{ .light }}" data-dark="{{ .dark }}" content="{{ .light }}">
|
||||
{{- end -}}
|
||||
|
||||
{{- with .Site.Params.app.tileColor -}}
|
||||
{{- with .Site.Params.app.tile_color -}}
|
||||
<meta name="msapplication-TileColor" content="{{ . }}">
|
||||
{{- end -}}
|
||||
|
||||
@@ -76,9 +72,9 @@
|
||||
{{- end -}}
|
||||
|
||||
{{- /* Favicon links (static resources, generated via https://realfavicongenerator.net/) */ -}}
|
||||
{{- if not .Site.Params.app.noFavicon -}}
|
||||
{{- if not .Site.Params.app.no_favicon -}}
|
||||
{{- $relURL := relURL "" -}}
|
||||
{{- with .Site.Params.app.svgFavicon -}}
|
||||
{{- with .Site.Params.app.svg_favicon -}}
|
||||
<link rel="icon" href="{{ . }}">
|
||||
{{- else -}}
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{{ $relURL }}favicon.ico" />
|
||||
@@ -86,11 +82,11 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ $relURL }}favicon-16x16.png">
|
||||
{{- end -}}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ $relURL }}apple-touch-icon.png">
|
||||
{{- with .Site.Params.app.iconColor -}}
|
||||
{{- with .Site.Params.app.mask_color -}}
|
||||
<link rel="mask-icon" href="{{ $relURL }}safari-pinned-tab.svg" color="{{ . }}">
|
||||
{{- end -}}
|
||||
{{- if eq .Site.Params.enablePWA true -}}
|
||||
<link rel="manifest" href="{{ $relURL }}site.webmanifest">
|
||||
{{- with .OutputFormats.Get "manifest" -}}
|
||||
{{- if eq $.Site.Params.app.pwa true -}}<link rel="manifest" href="{{ .RelPermalink }}">{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
|
||||
@@ -135,6 +135,17 @@
|
||||
{{- /* Custom widgets */ -}}
|
||||
{{- block "custom-widgets" . }}{{ end -}}
|
||||
|
||||
{{- /* Service Worker Update Notification */ -}}
|
||||
{{- if .Site.Params.app.pwa -}}
|
||||
<div class="sw-update-notification">
|
||||
<div class="sw-update-content">
|
||||
<p class="sw-update-title">{{ T "serviceWorker.updateTitle" }}</p>
|
||||
<p class="sw-update-text">{{ T "serviceWorker.updateText" }}</p>
|
||||
</div>
|
||||
<button type="button" class="sw-update-btn">{{ T "assets.refresh" }}</button>
|
||||
</div>
|
||||
{{- end -}}
|
||||
|
||||
<noscript>
|
||||
<div class="noscript-warning">{{ T "baseof.noscript" }}</div>
|
||||
</noscript>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{{- if .Site.Params.app.pwa -}}
|
||||
{{- $name := .Site.Params.app.name | default .Site.Title -}}
|
||||
{{- $shortName := .Site.Params.app.short_name | default $name -}}
|
||||
{{- $startURL := relURL "/" -}}
|
||||
{{- $display := "standalone" -}}
|
||||
{{- $themeColor := "" -}}
|
||||
{{- with .Site.Params.app.theme_color -}}
|
||||
{{- $themeColor = .light -}}
|
||||
{{- end -}}
|
||||
{{- $bgColor := .Site.Params.appearance.global_background_color | default "#ffffff" -}}
|
||||
|
||||
{{- /* Build icons array from params.app.icons, with fallback defaults */ -}}
|
||||
{{- $icons := slice -}}
|
||||
{{- with .Site.Params.app.icons -}}
|
||||
{{- range . -}}
|
||||
{{- $icon := dict "src" .src "sizes" .sizes "type" .type -}}
|
||||
{{- with .purpose -}}
|
||||
{{- $icon = $icon | merge (dict "purpose" .) -}}
|
||||
{{- end -}}
|
||||
{{- $icons = $icons | append $icon -}}
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
{{- $icons = $icons | append (dict "src" (printf "%sapple-touch-icon.png" (relURL "")) "sizes" "180x180" "type" "image/png" "purpose" "any maskable") -}}
|
||||
{{- $icons = $icons | append (dict "src" (printf "%sandroid-chrome-192x192.png" (relURL "")) "sizes" "192x192" "type" "image/png") -}}
|
||||
{{- $icons = $icons | append (dict "src" (printf "%sandroid-chrome-512x512.png" (relURL "")) "sizes" "512x512" "type" "image/png") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- $manifest := dict "name" $name "short_name" $shortName "start_url" $startURL "display" $display "theme_color" $themeColor "background_color" $bgColor "icons" $icons -}}
|
||||
{{- $manifest | jsonify (dict "indent" " ") -}}
|
||||
{{- end -}}
|
||||
Reference in New Issue
Block a user