Update favicon and enhance UI components for improved user experience

- Replaced PNG favicon with SVG for better scalability and visual quality across devices.
- Updated Vite configuration to include the new SVG favicon and adjusted asset paths.
- Enhanced various UI components with improved focus management and accessibility features.
- Introduced new styles to hide scrollbars while maintaining scroll functionality for a cleaner interface.
This commit is contained in:
Dorian 2026-02-17 22:10:38 +00:00
parent 8a32e36d85
commit b63612c5ae
21 changed files with 486 additions and 107 deletions

View File

@ -2,16 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="96x96" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="128x128" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/assets/img/favico.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/assets/img/favico.png" />
<link rel="icon" type="image/svg+xml" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="72x72" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="96x96" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="128x128" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="144x144" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="152x152" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="192x192" href="/assets/icon/favico-black.svg" />
<link rel="apple-touch-icon" sizes="512x512" href="/assets/icon/favico-black.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Archipelago - Your sovereign personal server" />
<meta name="theme-color" content="#000000" />
@ -21,7 +21,7 @@
<meta name="apple-mobile-web-app-title" content="Archipelago" />
<meta name="application-name" content="Archipelago" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="msapplication-TileImage" content="/assets/img/favico.png" />
<meta name="msapplication-TileImage" content="/assets/icon/favico-black.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Archipelago OS</title>
</head>

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1" fill="none">
<!-- White B symbol only - no orange circle -->
<g transform="scale(0.015625)">
<path fill="#ffffff" d="m 46.1009,27.441 c 0.637,-4.258 -2.605,-6.547 -7.038,-8.074 l 1.438,-5.768 -3.511,-0.875 -1.4,5.616 c -0.923,-0.23 -1.871,-0.447 -2.813,-0.662 l 1.41,-5.653 -3.509,-0.875 -1.439,5.766 c -0.764,-0.174 -1.514,-0.346 -2.242,-0.527 l 0.004,-0.018 -4.842,-1.209 -0.934,3.75 c 0,0 2.605,0.597 2.55,0.634 1.422,0.355 1.679,1.296 1.636,2.042 l -1.638,6.571 c 0.098,0.025 0.225,0.061 0.365,0.117 -0.117,-0.029 -0.242,-0.061 -0.371,-0.092 l -2.296,9.205 c -0.174,0.432 -0.615,1.08 -1.609,0.834 0.035,0.051 -2.552,-0.637 -2.552,-0.637 l -1.743,4.019 4.569,1.139 c 0.85,0.213 1.683,0.436 2.503,0.646 l -1.453,5.834 3.507,0.875 1.439,-5.772 c 0.958,0.26 1.888,0.5 2.798,0.726 l -1.434,5.745 3.511,0.875 1.453,-5.823 c 5.987,1.133 10.489,0.676 12.384,-4.739 1.527,-4.36 -0.076,-6.875 -3.226,-8.515 2.294,-0.529 4.022,-2.038 4.483,-5.155 z m -8.022,11.249 c -1.085,4.36 -8.426,2.003 -10.806,1.412 l 1.928,-7.729 c 2.38,0.594 10.012,1.77 8.878,6.317 z m 1.086,-11.312 c -0.99,3.966 -7.1,1.951 -9.082,1.457 l 1.748,-7.01 c 1.982,0.494 8.365,1.416 7.334,5.553 z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -100,9 +100,22 @@ function onUserActivity() {
function onKeyDown(e: KeyboardEvent) {
const isMac = navigator.platform.toUpperCase().includes('MAC')
const mod = isMac ? e.metaKey : e.ctrlKey
if (mod && e.key === 'k') {
// Cmd+K / Ctrl+K or plain K (when not typing in input)
const target = e.target as HTMLElement
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
if ((mod && e.key === 'k') || ((e.key === 'k' || e.key === 'K') && !isInput)) {
e.preventDefault()
spotlightStore.toggle()
return
}
// 's' key activates screensaver when authenticated (skip if typing in input)
if (e.key === 's' || e.key === 'S') {
const target = e.target as HTMLElement
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive) {
e.preventDefault()
screensaverStore.activate()
}
}
}

View File

@ -1,8 +1,14 @@
<template>
<div class="logo-gradient-border flex-shrink-0 inline-block overflow-hidden" :class="sizeClass">
<div
class="flex-shrink-0 inline-block overflow-hidden"
:class="[
sizeClass,
!noBorder && 'logo-gradient-border'
]"
>
<!-- Neode logo - always white -->
<svg
class="block logo-svg"
class="block w-full h-full logo-svg"
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@ -27,9 +33,18 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
size?: 'sm' | 'lg' | 'xl'
}>(), { size: 'sm' })
noBorder?: boolean
/** When true, fit to container (w-full h-full) instead of fixed size - for use inside logo-gradient-border */
fit?: boolean
}>(), { size: 'sm', noBorder: false, fit: false })
const sizeClass = props.size === 'xl' ? 'w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80' : props.size === 'lg' ? 'w-32 h-32 sm:w-48 sm:h-48' : 'w-14 h-14'
const sizeClass = props.fit
? 'w-full h-full max-w-full max-h-full'
: props.size === 'xl'
? 'w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80'
: props.size === 'lg'
? 'w-32 h-32 sm:w-48 sm:h-48'
: 'w-14 h-14'
// Parsed from favico-black.svg path - 20 rects
const rects = [
@ -56,7 +71,6 @@ const rects = [
]
// Stagger delays (ms) - row-by-row top-to-bottom, left-to-right for a clean reveal
// Row 1 (y~318): 0,1,2,3 | Row 2 (y~396): 4,5 | Row 3 (y~476): 6-11 | Row 4 (y~555): 12-15 | Row 5 (y~634): 16-19
const delays = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
</script>

View File

@ -36,13 +36,15 @@
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- Iframe container -->
<div class="relative flex-1 min-h-0 bg-black/40">
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<iframe
ref="iframeRef"
v-if="store.url"
:src="store.url"
class="absolute inset-0 w-full h-full border-0"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="injectScrollbarHideIfSameOrigin"
/>
</div>
</div>
@ -57,6 +59,22 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
const store = useAppLauncherStore()
const closeBtnRef = ref<HTMLButtonElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
function injectScrollbarHideIfSameOrigin() {
try {
const doc = iframeRef.value?.contentDocument
if (!doc) return
const style = doc.createElement('style')
style.textContent = `
* { -ms-overflow-style: none; scrollbar-width: none; }
*::-webkit-scrollbar { display: none; }
`
doc.head.appendChild(style)
} catch {
/* Cross-origin: cannot access iframe document */
}
}
const panelClasses = [
'glass-card',

View File

@ -8,6 +8,7 @@
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
@click.stop
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
>
@ -45,14 +46,20 @@
</template>
<script setup lang="ts">
defineProps<{
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const props = defineProps<{
show: boolean
title: string
content: string
relatedPath?: string
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('close'))
</script>

View File

@ -1,39 +1,59 @@
<template>
<Transition name="fade">
<div
v-if="showUpdatePrompt"
class="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 z-[9999]"
>
<div class="glass-card p-4 flex items-center gap-4">
<div class="flex-1">
<p class="text-white/90 text-sm font-medium mb-1">Update Available</p>
<p class="text-white/70 text-xs">A new version is available. Click to update.</p>
<Teleport to="body">
<Transition name="modal">
<div
v-if="showUpdatePrompt"
class="fixed inset-0 z-[9999] flex items-center justify-center p-4"
@click.self="dismissUpdate"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="modalRef"
class="glass-card p-6 max-w-md w-full relative z-10"
@click.stop
>
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-xl font-semibold text-white">Update Available</h3>
<button
@click="dismissUpdate"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p class="text-white/80 mb-6">
A new version of Archipelago is available. Update now to get the latest features and fixes.
</p>
<div class="flex gap-3 justify-end">
<button
@click="dismissUpdate"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
Later
</button>
<button
@click="handleUpdate"
class="px-4 py-2 gradient-button rounded-lg text-sm font-medium"
>
Update Now
</button>
</div>
</div>
<button
@click="handleUpdate"
class="glass-button px-4 py-2 rounded-lg text-sm font-medium transition-all hover:bg-black/70 hover:border-white/30"
>
Update
</button>
<button
@click="dismissUpdate"
class="text-white/50 hover:text-white/80 transition-colors p-2"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const showUpdatePrompt = ref(false)
let updateCallback: (() => Promise<void>) | null = null
const modalRef = ref<HTMLElement | null>(null)
onMounted(() => {
// Listen for service worker updates
@ -54,6 +74,13 @@ onMounted(() => {
// Check for updates every 5 minutes
setInterval(checkForUpdates, 5 * 60 * 1000)
// Check when user returns to tab (helps with cached PWA)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkForUpdates()
}
})
// Listen for updatefound event
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration) {
@ -79,6 +106,8 @@ onMounted(() => {
}
})
useModalKeyboard(modalRef, showUpdatePrompt, dismissUpdate)
function dismissUpdate() {
showUpdatePrompt.value = false
}
@ -89,17 +118,3 @@ async function handleUpdate() {
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style>

View File

@ -19,8 +19,8 @@
/>
</div>
<!-- Logo in center -->
<div class="screensaver-logo-wrapper relative z-10">
<AnimatedLogo size="xl" />
<div class="screensaver-logo-wrapper">
<ScreensaverLogo />
</div>
</div>
</div>
@ -30,7 +30,7 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
import { useScreensaverStore } from '@/stores/screensaver'
const store = useScreensaverStore()
@ -100,12 +100,14 @@ onBeforeUnmount(() => {
}
}
/* Ring of segments around the logo - audio viz style */
/* Ring of segments around the logo - audio viz style (behind logo) */
.screensaver-viz-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
--viz-radius: 140px;
}
@ -175,6 +177,7 @@ onBeforeUnmount(() => {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<div class="logo-gradient-border screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
<!-- Squares logo -->
<div class="screensaver-logo-squares absolute inset-[3px] flex items-center justify-center">
<AnimatedLogo size="xl" no-border fit />
</div>
</div>
</template>
<script setup lang="ts">
import AnimatedLogo from '@/components/AnimatedLogo.vue'
</script>
<style scoped>
.screensaver-logo-squares {
opacity: 1;
position: relative;
z-index: 1;
}
</style>

View File

@ -218,6 +218,16 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return
}
}
// My Apps: Enter = launch (click Launch button when app is runnable)
if (el.hasAttribute('data-controller-launch')) {
const launchBtn = el.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
if (launchBtn) {
playNavSound('action')
launchBtn.click()
e.preventDefault()
return
}
}
// My Apps, etc: Enter = focus first inner control
const inner = getInnerFocusables(el)
const firstInner = inner[0]

View File

@ -0,0 +1,74 @@
/**
* Modal keyboard navigation: Escape to close, Arrow keys to move between buttons.
* Restores focus to the previously active element when closing via Escape.
*/
import { onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
export interface UseModalKeyboardOptions {
restoreFocusRef?: Ref<HTMLElement | null>
}
export function useModalKeyboard(
containerRef: Ref<HTMLElement | null>,
isOpen: Ref<boolean>,
onClose: () => void,
options?: UseModalKeyboardOptions
) {
const restoreFocusRef = options?.restoreFocusRef
// Save the element that had focus when modal opens (before focus moves to modal)
watch(isOpen, (open) => {
if (open && restoreFocusRef) {
restoreFocusRef.value = document.activeElement as HTMLElement | null
}
})
function getFocusables(): HTMLElement[] {
const el = containerRef.value
if (!el) return []
return Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(e) => e.offsetParent !== null
)
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen.value) return
if (e.key === 'Escape') {
restoreFocusRef?.value?.focus?.()
onClose()
e.preventDefault()
e.stopPropagation()
return
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
const focusables = getFocusables()
if (focusables.length === 0) return
const current = document.activeElement as HTMLElement | null
const idx = current ? focusables.indexOf(current) : -1
let nextIdx: number
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
nextIdx = idx < focusables.length - 1 ? idx + 1 : 0
} else {
nextIdx = idx > 0 ? idx - 1 : focusables.length - 1
}
focusables[nextIdx]?.focus()
e.preventDefault()
e.stopPropagation()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown, true)
})
}

View File

@ -70,8 +70,10 @@ export const useSpotlightStore = defineStore('spotlight', () => {
content: '',
relatedPath: undefined as string | undefined,
})
const helpModalRestoreFocusRef = ref<HTMLElement | null>(null)
function showHelpModal(payload: { title: string; content: string; relatedPath?: string }) {
helpModalRestoreFocusRef.value = document.activeElement as HTMLElement | null
helpModal.show = true
helpModal.title = payload.title
helpModal.content = payload.content
@ -79,6 +81,8 @@ export const useSpotlightStore = defineStore('spotlight', () => {
}
function closeHelpModal() {
helpModalRestoreFocusRef.value?.focus?.()
helpModalRestoreFocusRef.value = null
helpModal.show = false
}

View File

@ -444,6 +444,28 @@ body {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 100%);
}
/* Hide scrollbar but keep scroll functionality - applied globally to all scrollable content */
.scrollbar-hide,
.overflow-y-auto,
.overflow-auto,
.overflow-y-scroll,
.iframe-scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar,
.overflow-y-auto::-webkit-scrollbar,
.overflow-auto::-webkit-scrollbar,
.overflow-y-scroll::-webkit-scrollbar,
.iframe-scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Iframe scrollbar hide - targets iframe element; inner doc scrollbars need same-origin injection */
iframe.iframe-scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Animations */
@keyframes fadeUpIn {
0% {

View File

@ -383,10 +383,11 @@
<div
v-if="uninstallModal.show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click="uninstallModal.show = false"
@click="closeUninstallModal()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="uninstallModalRef"
@click.stop
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
>
@ -407,7 +408,7 @@
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
<button
@click="uninstallModal.show = false"
@click="closeUninstallModal()"
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
>
Cancel
@ -432,6 +433,7 @@ import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState } from '../types/api'
import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { dummyApps } from '../utils/dummyApps'
const { bottomPosition } = useMobileBackButton()
@ -510,6 +512,18 @@ const uninstallModal = ref({
show: false,
appTitle: ''
})
const uninstallModalRef = ref<HTMLElement | null>(null)
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
function closeUninstallModal() {
uninstallRestoreFocusRef.value?.focus?.()
uninstallModal.value.show = false
}
useModalKeyboard(
uninstallModalRef,
computed(() => uninstallModal.value.show),
closeUninstallModal,
{ restoreFocusRef: uninstallRestoreFocusRef }
)
// Determine back button text based on where user came from
const backButtonText = computed(() => {
@ -607,6 +621,10 @@ function launchApp() {
dev: 'http://localhost:8103',
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'http://localhost:7777',
prod: 'http://localhost:7777' // Containerized indeehub prototype
},
// Dummy apps - replace with real URLs when packaged
'bitcoin': {
dev: 'http://localhost:8332',
@ -663,7 +681,11 @@ function launchApp() {
}
if (appUrls[id]) {
const url = isDev ? appUrls[id].dev : appUrls[id].prod
let url = isDev ? appUrls[id].dev : appUrls[id].prod
// Replace localhost with current hostname for remote access
if (url.includes('localhost')) {
url = url.replace('localhost', window.location.hostname)
}
useAppLauncherStore().open({ url, title: pkg.value.manifest.title })
return
}

View File

@ -28,6 +28,7 @@
v-for="[id, pkg] in sortedPackageEntries"
:key="id"
data-controller-container
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
tabindex="0"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
@click="goToApp(id as string)"
@ -75,6 +76,7 @@
<div class="mt-4 flex gap-2">
<button
v-if="canLaunch(pkg)"
data-controller-launch-btn
@click.stop="launchApp(id as string)"
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium"
>
@ -125,10 +127,11 @@
<div
v-if="uninstallModal.show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click="uninstallModal.show = false"
@click="closeUninstallModal()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="uninstallModalRef"
@click.stop
class="glass-card p-6 max-w-md w-full relative z-10"
>
@ -149,7 +152,7 @@
<div class="flex gap-3 justify-end">
<button
@click="uninstallModal.show = false"
@click="closeUninstallModal()"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
>
Cancel
@ -173,6 +176,7 @@ import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
const store = useAppStore()
@ -200,6 +204,18 @@ const uninstallModal = ref({
appId: '',
appTitle: ''
})
const uninstallModalRef = ref<HTMLElement | null>(null)
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
function closeUninstallModal() {
uninstallRestoreFocusRef.value?.focus?.()
uninstallModal.value.show = false
}
useModalKeyboard(
uninstallModalRef,
computed(() => uninstallModal.value.show),
closeUninstallModal,
{ restoreFocusRef: uninstallRestoreFocusRef }
)
function canLaunch(pkg: any): boolean {
// For dummy apps, allow launch if running (they have interface addresses)
@ -237,6 +253,10 @@ function launchApp(id: string) {
'k484': {
dev: 'http://localhost:8103',
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'http://localhost:7777',
prod: 'http://localhost:7777' // Containerized indeehub prototype
}
}

View File

@ -218,12 +218,12 @@
<div
v-if="showSideloadModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="showSideloadModal = false"
@click.self="closeSideloadModal()"
>
<div class="glass-card p-8 max-w-2xl w-full relative">
<div ref="sideloadModalRef" class="glass-card p-8 max-w-2xl w-full relative">
<!-- Close Button -->
<button
@click="showSideloadModal = false"
@click="closeSideloadModal()"
class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -289,14 +289,14 @@
<div
v-if="showFilterModal"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/60 backdrop-blur-sm"
@click.self="showFilterModal = false"
@click.self="closeFilterModal()"
>
<div class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">Filter by Category</h2>
<button
@click="showFilterModal = false"
@click="closeFilterModal()"
class="text-white/60 hover:text-white transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -310,7 +310,7 @@
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id; showFilterModal = false"
@click="selectedCategory = category.id; closeFilterModal()"
:class="[
'p-4 rounded-xl font-medium transition-all text-left',
selectedCategory === category.id
@ -372,6 +372,7 @@ import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useMobileBackButton } from '@/composables/useMobileBackButton'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
const store = useAppStore()
@ -408,6 +409,13 @@ const maxAttempts = ref(60)
// Sideload modal state
const showSideloadModal = ref(false)
const sideloadModalRef = ref<HTMLElement | null>(null)
const sideloadRestoreFocusRef = ref<HTMLElement | null>(null)
function closeSideloadModal() {
sideloadRestoreFocusRef.value?.focus?.()
showSideloadModal.value = false
}
useModalKeyboard(sideloadModalRef, showSideloadModal, closeSideloadModal, { restoreFocusRef: sideloadRestoreFocusRef })
const sideloadUrl = ref('')
const sideloading = ref(false)
const sideloadError = ref('')
@ -415,6 +423,13 @@ const sideloadSuccess = ref('')
// Filter modal state (for mobile)
const showFilterModal = ref(false)
const filterModalRef = ref<HTMLElement | null>(null)
const filterRestoreFocusRef = ref<HTMLElement | null>(null)
function closeFilterModal() {
filterRestoreFocusRef.value?.focus?.()
showFilterModal.value = false
}
useModalKeyboard(filterModalRef, showFilterModal, closeFilterModal, { restoreFocusRef: filterRestoreFocusRef })
// Community marketplace state
const loadingCommunity = ref(false)

View File

@ -108,9 +108,9 @@
<div
v-if="showChangePasswordModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="showChangePasswordModal = false"
@click.self="closeChangePasswordModal()"
>
<div class="glass-card p-6 max-w-md w-full">
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">Change Password</h3>
<p class="text-white/70 text-sm mb-4">Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).</p>
<form @submit.prevent="handleChangePassword" class="space-y-4">
@ -206,6 +206,7 @@ import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/app'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
const store = useAppStore()
@ -225,6 +226,9 @@ const userDid = computed(() => {
const copiedOnion = ref(false)
const showChangePasswordModal = ref(false)
const changePasswordModalRef = ref<HTMLElement | null>(null)
const changePasswordRestoreFocusRef = ref<HTMLElement | null>(null)
useModalKeyboard(changePasswordModalRef, showChangePasswordModal, closeChangePasswordModal, { restoreFocusRef: changePasswordRestoreFocusRef })
const changingPassword = ref(false)
const changePasswordError = ref('')
const changePasswordSuccess = ref('')
@ -301,6 +305,7 @@ async function copyOnionAddress() {
}
function closeChangePasswordModal() {
changePasswordRestoreFocusRef.value?.focus?.()
showChangePasswordModal.value = false
changePasswordError.value = ''
changePasswordSuccess.value = ''

View File

@ -108,8 +108,8 @@
<!-- Send Message Modal -->
<Teleport to="body">
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showSendMessageModal = false">
<div class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="closeSendMessageModal()">
<div ref="sendMessageModalRef" class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<h3 class="text-lg font-semibold text-white mb-4">Send Message (over Tor)</h3>
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
<div class="space-y-4">
@ -144,7 +144,7 @@
{{ sendingMessage ? 'Sending...' : 'Send' }}
</button>
<button
@click="showSendMessageModal = false"
@click="closeSendMessageModal()"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
Cancel
@ -630,6 +630,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const route = useRoute()
const messageToast = useMessageToast()
@ -666,6 +667,13 @@ const connectedNodesCount = computed(() => peers.value.length)
// Send Message modal
const showSendMessageModal = ref(false)
const sendMessageModalRef = ref<HTMLElement | null>(null)
const sendMessageRestoreFocusRef = ref<HTMLElement | null>(null)
function closeSendMessageModal() {
sendMessageRestoreFocusRef.value?.focus?.()
showSendMessageModal.value = false
}
useModalKeyboard(sendMessageModalRef, showSendMessageModal, closeSendMessageModal, { restoreFocusRef: sendMessageRestoreFocusRef })
const sendMessageTo = ref('')
const sendMessageText = ref('')
const sendingMessage = ref(false)

View File

@ -9,7 +9,7 @@ export default defineConfig({
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favico.png', 'favico.svg', 'favicon.ico'],
includeAssets: ['assets/icon/favico-black.svg', 'favico.svg', 'favicon.ico'],
manifest: {
name: 'Archipelago',
short_name: 'Archipelago',
@ -24,63 +24,63 @@ export default defineConfig({
prefer_related_applications: false,
icons: [
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '72x72',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '96x96',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '128x128',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '144x144',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '152x152',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '192x192',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '384x384',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '512x512',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '192x192',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'maskable'
},
{
src: '/assets/img/favico.png',
src: '/assets/icon/favico-black.svg',
sizes: '512x512',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'maskable'
}
],
@ -90,21 +90,21 @@ export default defineConfig({
short_name: 'Dashboard',
description: 'Open the dashboard',
url: '/dashboard',
icons: [{ src: '/assets/img/favico.png', sizes: '192x192' }]
icons: [{ src: '/assets/icon/favico-black.svg', sizes: '192x192' }]
},
{
name: 'My Apps',
short_name: 'Apps',
description: 'Manage your apps',
url: '/apps',
icons: [{ src: '/assets/img/favico.png', sizes: '192x192' }]
icons: [{ src: '/assets/icon/favico-black.svg', sizes: '192x192' }]
},
{
name: 'App Store',
short_name: 'Store',
description: 'Browse and install apps',
url: '/marketplace',
icons: [{ src: '/assets/img/favico.png', sizes: '192x192' }]
icons: [{ src: '/assets/icon/favico-black.svg', sizes: '192x192' }]
}
]
},

View File

@ -137,6 +137,10 @@ if [ "$LIVE" = true ]; then
echo " Restarting services..."
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
# Set up HTTPS for PWA installability (browsers require secure context)
echo " Setting up HTTPS for PWA install..."
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo bash $TARGET_DIR/scripts/setup-https-dev.sh" 2>&1 | sed 's/^/ /' || true
# Rebuild and recreate LND UI container (port 8081 so Launch from UI and http://host:8081 both work)
echo " Rebuilding LND UI..."
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t lnd-ui:latest . || sudo docker build --no-cache -t lnd-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
@ -428,6 +432,7 @@ if [ "$LIVE" = true ]; then
echo "✅ Deployed to live system!"
echo " Backend: $(sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
echo " Web UI: http://$(echo $TARGET_HOST | cut -d@ -f2)"
echo " PWA install: https://$(echo $TARGET_HOST | cut -d@ -f2) (use HTTPS, accept cert once, then Install app)"
else
echo ""
echo "✅ Build complete!"

View File

@ -0,0 +1,98 @@
#!/bin/bash
#
# Set up HTTPS on Archipelago dev server for PWA installability.
# Browsers require HTTPS (or localhost) to install PWAs.
# Generates a self-signed certificate and configures nginx.
#
# Run on the target server: sudo ./setup-https-dev.sh
# Or via deploy: the deploy script runs this automatically.
#
set -e
SSL_DIR="/etc/archipelago/ssl"
NGINX_CFG="/etc/nginx/sites-available/archipelago"
CERT="$SSL_DIR/archipelago.crt"
KEY="$SSL_DIR/archipelago.key"
# Create SSL directory
mkdir -p "$SSL_DIR"
chmod 755 "$SSL_DIR"
# Generate self-signed cert if missing (valid 365 days)
# SAN includes common dev IPs so cert works when accessing via IP
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
echo "Generating self-signed certificate for PWA (HTTPS)..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$KEY" \
-out "$CERT" \
-subj "/CN=archipelago.local/O=Archipelago/C=US" \
-addext "subjectAltName=DNS:archipelago.local,DNS:localhost,IP:127.0.0.1,IP:192.168.1.228,IP:192.168.1.198,IP:10.0.0.1"
chmod 644 "$CERT"
chmod 600 "$KEY"
echo " Certificate created at $CERT"
fi
# Check if HTTPS is already configured
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
echo "HTTPS already configured in nginx."
nginx -t 2>/dev/null && systemctl reload nginx
exit 0
fi
# Add HTTPS server block (duplicate of HTTP block with SSL)
# Insert after the closing brace of the first server block
HTTPS_BLOCK='
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl;
server_name _;
ssl_certificate '"$CERT"';
ssl_certificate_key '"$KEY"';
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
root /opt/archipelago/web-ui;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /archipelago/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /rpc/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /ws {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
}
'
# Append HTTPS block to nginx config
echo "$HTTPS_BLOCK" >> "$NGINX_CFG"
echo "Added HTTPS (port 443) to nginx config."
# Test and reload
nginx -t && systemctl reload nginx
echo ""
echo "HTTPS enabled. Access via https://192.168.1.228 (accept the certificate warning once to install PWA)."