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:
parent
8a32e36d85
commit
b63612c5ae
@ -2,16 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" 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/img/favico.png" />
|
<link rel="apple-touch-icon" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="/assets/img/favico.png" />
|
<link rel="apple-touch-icon" sizes="72x72" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="96x96" href="/assets/img/favico.png" />
|
<link rel="apple-touch-icon" sizes="96x96" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="128x128" href="/assets/img/favico.png" />
|
<link rel="apple-touch-icon" sizes="128x128" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="144x144" href="/assets/img/favico.png" />
|
<link rel="apple-touch-icon" sizes="144x144" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/assets/img/favico.png" />
|
<link rel="apple-touch-icon" sizes="152x152" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/img/favico.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="/assets/img/favico.png" />
|
<link rel="apple-touch-icon" sizes="192x192" href="/assets/icon/favico-black.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href="/assets/img/favico.png" />
|
<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="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="description" content="Archipelago - Your sovereign personal server" />
|
<meta name="description" content="Archipelago - Your sovereign personal server" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Archipelago" />
|
<meta name="apple-mobile-web-app-title" content="Archipelago" />
|
||||||
<meta name="application-name" content="Archipelago" />
|
<meta name="application-name" content="Archipelago" />
|
||||||
<meta name="msapplication-TileColor" content="#000000" />
|
<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" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Archipelago OS</title>
|
<title>Archipelago OS</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
6
neode-ui/public/assets/img/icons/bitcoin-symbol.svg
Normal file
6
neode-ui/public/assets/img/icons/bitcoin-symbol.svg
Normal 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 |
@ -100,9 +100,22 @@ function onUserActivity() {
|
|||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||||
const mod = isMac ? e.metaKey : e.ctrlKey
|
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()
|
e.preventDefault()
|
||||||
spotlightStore.toggle()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
<template>
|
<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 -->
|
<!-- Neode logo - always white -->
|
||||||
<svg
|
<svg
|
||||||
class="block logo-svg"
|
class="block w-full h-full logo-svg"
|
||||||
viewBox="0 0 1024 1024"
|
viewBox="0 0 1024 1024"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -27,9 +33,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
size?: 'sm' | 'lg' | 'xl'
|
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
|
// Parsed from favico-black.svg path - 20 rects
|
||||||
const 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
|
// 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]
|
const delays = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Iframe container -->
|
<!-- Iframe container - overflow hidden to clip inner scrollbars -->
|
||||||
<div class="relative flex-1 min-h-0 bg-black/40">
|
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
|
||||||
<iframe
|
<iframe
|
||||||
|
ref="iframeRef"
|
||||||
v-if="store.url"
|
v-if="store.url"
|
||||||
:src="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"
|
title="App content"
|
||||||
|
@load="injectScrollbarHideIfSameOrigin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -57,6 +59,22 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
|||||||
|
|
||||||
const store = useAppLauncherStore()
|
const store = useAppLauncherStore()
|
||||||
const closeBtnRef = ref<HTMLButtonElement | null>(null)
|
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 = [
|
const panelClasses = [
|
||||||
'glass-card',
|
'glass-card',
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
<div
|
<div
|
||||||
|
ref="modalRef"
|
||||||
@click.stop
|
@click.stop
|
||||||
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
|
class="glass-card p-6 max-w-lg w-full relative z-10 max-h-[80vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
@ -45,14 +46,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { ref, computed } from 'vue'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
relatedPath?: string
|
relatedPath?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const modalRef = ref<HTMLElement | null>(null)
|
||||||
|
useModalKeyboard(modalRef, computed(() => props.show), () => emit('close'))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,39 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<Transition name="fade">
|
<Teleport to="body">
|
||||||
<div
|
<Transition name="modal">
|
||||||
v-if="showUpdatePrompt"
|
<div
|
||||||
class="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 z-[9999]"
|
v-if="showUpdatePrompt"
|
||||||
>
|
class="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||||
<div class="glass-card p-4 flex items-center gap-4">
|
@click.self="dismissUpdate"
|
||||||
<div class="flex-1">
|
>
|
||||||
<p class="text-white/90 text-sm font-medium mb-1">Update Available</p>
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
<p class="text-white/70 text-xs">A new version is available. Click to update.</p>
|
<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>
|
</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>
|
||||||
</div>
|
</Transition>
|
||||||
</Transition>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
const showUpdatePrompt = ref(false)
|
const showUpdatePrompt = ref(false)
|
||||||
let updateCallback: (() => Promise<void>) | null = null
|
let updateCallback: (() => Promise<void>) | null = null
|
||||||
|
const modalRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Listen for service worker updates
|
// Listen for service worker updates
|
||||||
@ -54,6 +74,13 @@ onMounted(() => {
|
|||||||
// Check for updates every 5 minutes
|
// Check for updates every 5 minutes
|
||||||
setInterval(checkForUpdates, 5 * 60 * 1000)
|
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
|
// Listen for updatefound event
|
||||||
navigator.serviceWorker.getRegistration().then((registration) => {
|
navigator.serviceWorker.getRegistration().then((registration) => {
|
||||||
if (registration) {
|
if (registration) {
|
||||||
@ -79,6 +106,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useModalKeyboard(modalRef, showUpdatePrompt, dismissUpdate)
|
||||||
|
|
||||||
function dismissUpdate() {
|
function dismissUpdate() {
|
||||||
showUpdatePrompt.value = false
|
showUpdatePrompt.value = false
|
||||||
}
|
}
|
||||||
@ -89,17 +118,3 @@ async function handleUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Logo in center -->
|
<!-- Logo in center -->
|
||||||
<div class="screensaver-logo-wrapper relative z-10">
|
<div class="screensaver-logo-wrapper">
|
||||||
<AnimatedLogo size="xl" />
|
<ScreensaverLogo />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onBeforeUnmount } from 'vue'
|
import { onMounted, onBeforeUnmount } from 'vue'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
|
||||||
import { useScreensaverStore } from '@/stores/screensaver'
|
import { useScreensaverStore } from '@/stores/screensaver'
|
||||||
|
|
||||||
const store = useScreensaverStore()
|
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 {
|
.screensaver-viz-ring {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
--viz-radius: 140px;
|
--viz-radius: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +177,7 @@ onBeforeUnmount(() => {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 10;
|
||||||
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
20
neode-ui/src/components/ScreensaverLogo.vue
Normal file
20
neode-ui/src/components/ScreensaverLogo.vue
Normal 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>
|
||||||
@ -218,6 +218,16 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
return
|
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
|
// My Apps, etc: Enter = focus first inner control
|
||||||
const inner = getInnerFocusables(el)
|
const inner = getInnerFocusables(el)
|
||||||
const firstInner = inner[0]
|
const firstInner = inner[0]
|
||||||
|
|||||||
74
neode-ui/src/composables/useModalKeyboard.ts
Normal file
74
neode-ui/src/composables/useModalKeyboard.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -70,8 +70,10 @@ export const useSpotlightStore = defineStore('spotlight', () => {
|
|||||||
content: '',
|
content: '',
|
||||||
relatedPath: undefined as string | undefined,
|
relatedPath: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
const helpModalRestoreFocusRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
function showHelpModal(payload: { title: string; content: string; relatedPath?: string }) {
|
function showHelpModal(payload: { title: string; content: string; relatedPath?: string }) {
|
||||||
|
helpModalRestoreFocusRef.value = document.activeElement as HTMLElement | null
|
||||||
helpModal.show = true
|
helpModal.show = true
|
||||||
helpModal.title = payload.title
|
helpModal.title = payload.title
|
||||||
helpModal.content = payload.content
|
helpModal.content = payload.content
|
||||||
@ -79,6 +81,8 @@ export const useSpotlightStore = defineStore('spotlight', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeHelpModal() {
|
function closeHelpModal() {
|
||||||
|
helpModalRestoreFocusRef.value?.focus?.()
|
||||||
|
helpModalRestoreFocusRef.value = null
|
||||||
helpModal.show = false
|
helpModal.show = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -444,6 +444,28 @@ body {
|
|||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 100%);
|
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 */
|
/* Animations */
|
||||||
@keyframes fadeUpIn {
|
@keyframes fadeUpIn {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@ -383,10 +383,11 @@
|
|||||||
<div
|
<div
|
||||||
v-if="uninstallModal.show"
|
v-if="uninstallModal.show"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
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 class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
<div
|
<div
|
||||||
|
ref="uninstallModalRef"
|
||||||
@click.stop
|
@click.stop
|
||||||
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
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">
|
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
||||||
<button
|
<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"
|
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@ -432,6 +433,7 @@ import { useAppStore } from '../stores/app'
|
|||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import { PackageState } from '../types/api'
|
import { PackageState } from '../types/api'
|
||||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
import { dummyApps } from '../utils/dummyApps'
|
import { dummyApps } from '../utils/dummyApps'
|
||||||
|
|
||||||
const { bottomPosition } = useMobileBackButton()
|
const { bottomPosition } = useMobileBackButton()
|
||||||
@ -510,6 +512,18 @@ const uninstallModal = ref({
|
|||||||
show: false,
|
show: false,
|
||||||
appTitle: ''
|
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
|
// Determine back button text based on where user came from
|
||||||
const backButtonText = computed(() => {
|
const backButtonText = computed(() => {
|
||||||
@ -607,6 +621,10 @@ function launchApp() {
|
|||||||
dev: 'http://localhost:8103',
|
dev: 'http://localhost:8103',
|
||||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
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
|
// Dummy apps - replace with real URLs when packaged
|
||||||
'bitcoin': {
|
'bitcoin': {
|
||||||
dev: 'http://localhost:8332',
|
dev: 'http://localhost:8332',
|
||||||
@ -663,7 +681,11 @@ function launchApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (appUrls[id]) {
|
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 })
|
useAppLauncherStore().open({ url, title: pkg.value.manifest.title })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
v-for="[id, pkg] in sortedPackageEntries"
|
v-for="[id, pkg] in sortedPackageEntries"
|
||||||
:key="id"
|
:key="id"
|
||||||
data-controller-container
|
data-controller-container
|
||||||
|
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||||
@click="goToApp(id as string)"
|
@click="goToApp(id as string)"
|
||||||
@ -75,6 +76,7 @@
|
|||||||
<div class="mt-4 flex gap-2">
|
<div class="mt-4 flex gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="canLaunch(pkg)"
|
v-if="canLaunch(pkg)"
|
||||||
|
data-controller-launch-btn
|
||||||
@click.stop="launchApp(id as string)"
|
@click.stop="launchApp(id as string)"
|
||||||
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium"
|
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
@ -125,10 +127,11 @@
|
|||||||
<div
|
<div
|
||||||
v-if="uninstallModal.show"
|
v-if="uninstallModal.show"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
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 class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
<div
|
<div
|
||||||
|
ref="uninstallModalRef"
|
||||||
@click.stop
|
@click.stop
|
||||||
class="glass-card p-6 max-w-md w-full relative z-10"
|
class="glass-card p-6 max-w-md w-full relative z-10"
|
||||||
>
|
>
|
||||||
@ -149,7 +152,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3 justify-end">
|
<div class="flex gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
@click="uninstallModal.show = false"
|
@click="closeUninstallModal()"
|
||||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@ -173,6 +176,7 @@ import { useRouter, RouterLink } from 'vue-router'
|
|||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import { PackageState } from '../types/api'
|
import { PackageState } from '../types/api'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
@ -200,6 +204,18 @@ const uninstallModal = ref({
|
|||||||
appId: '',
|
appId: '',
|
||||||
appTitle: ''
|
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 {
|
function canLaunch(pkg: any): boolean {
|
||||||
// For dummy apps, allow launch if running (they have interface addresses)
|
// For dummy apps, allow launch if running (they have interface addresses)
|
||||||
@ -237,6 +253,10 @@ function launchApp(id: string) {
|
|||||||
'k484': {
|
'k484': {
|
||||||
dev: 'http://localhost:8103',
|
dev: 'http://localhost:8103',
|
||||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||||
|
},
|
||||||
|
'indeedhub': {
|
||||||
|
dev: 'http://localhost:7777',
|
||||||
|
prod: 'http://localhost:7777' // Containerized indeehub prototype
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -218,12 +218,12 @@
|
|||||||
<div
|
<div
|
||||||
v-if="showSideloadModal"
|
v-if="showSideloadModal"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
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 -->
|
<!-- Close Button -->
|
||||||
<button
|
<button
|
||||||
@click="showSideloadModal = false"
|
@click="closeSideloadModal()"
|
||||||
class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors"
|
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">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -289,14 +289,14 @@
|
|||||||
<div
|
<div
|
||||||
v-if="showFilterModal"
|
v-if="showFilterModal"
|
||||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/60 backdrop-blur-sm"
|
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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-2xl font-bold text-white">Filter by Category</h2>
|
<h2 class="text-2xl font-bold text-white">Filter by Category</h2>
|
||||||
<button
|
<button
|
||||||
@click="showFilterModal = false"
|
@click="closeFilterModal()"
|
||||||
class="text-white/60 hover:text-white transition-colors"
|
class="text-white/60 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -310,7 +310,7 @@
|
|||||||
<button
|
<button
|
||||||
v-for="category in categoriesWithApps"
|
v-for="category in categoriesWithApps"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
@click="selectedCategory = category.id; showFilterModal = false"
|
@click="selectedCategory = category.id; closeFilterModal()"
|
||||||
:class="[
|
:class="[
|
||||||
'p-4 rounded-xl font-medium transition-all text-left',
|
'p-4 rounded-xl font-medium transition-all text-left',
|
||||||
selectedCategory === category.id
|
selectedCategory === category.id
|
||||||
@ -372,6 +372,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useMobileBackButton } from '@/composables/useMobileBackButton'
|
import { useMobileBackButton } from '@/composables/useMobileBackButton'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
@ -408,6 +409,13 @@ const maxAttempts = ref(60)
|
|||||||
|
|
||||||
// Sideload modal state
|
// Sideload modal state
|
||||||
const showSideloadModal = ref(false)
|
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 sideloadUrl = ref('')
|
||||||
const sideloading = ref(false)
|
const sideloading = ref(false)
|
||||||
const sideloadError = ref('')
|
const sideloadError = ref('')
|
||||||
@ -415,6 +423,13 @@ const sideloadSuccess = ref('')
|
|||||||
|
|
||||||
// Filter modal state (for mobile)
|
// Filter modal state (for mobile)
|
||||||
const showFilterModal = ref(false)
|
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
|
// Community marketplace state
|
||||||
const loadingCommunity = ref(false)
|
const loadingCommunity = ref(false)
|
||||||
|
|||||||
@ -108,9 +108,9 @@
|
|||||||
<div
|
<div
|
||||||
v-if="showChangePasswordModal"
|
v-if="showChangePasswordModal"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
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>
|
<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>
|
<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">
|
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||||
@ -206,6 +206,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
@ -225,6 +226,9 @@ const userDid = computed(() => {
|
|||||||
|
|
||||||
const copiedOnion = ref(false)
|
const copiedOnion = ref(false)
|
||||||
const showChangePasswordModal = 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 changingPassword = ref(false)
|
||||||
const changePasswordError = ref('')
|
const changePasswordError = ref('')
|
||||||
const changePasswordSuccess = ref('')
|
const changePasswordSuccess = ref('')
|
||||||
@ -301,6 +305,7 @@ async function copyOnionAddress() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeChangePasswordModal() {
|
function closeChangePasswordModal() {
|
||||||
|
changePasswordRestoreFocusRef.value?.focus?.()
|
||||||
showChangePasswordModal.value = false
|
showChangePasswordModal.value = false
|
||||||
changePasswordError.value = ''
|
changePasswordError.value = ''
|
||||||
changePasswordSuccess.value = ''
|
changePasswordSuccess.value = ''
|
||||||
|
|||||||
@ -108,8 +108,8 @@
|
|||||||
|
|
||||||
<!-- Send Message Modal -->
|
<!-- Send Message Modal -->
|
||||||
<Teleport to="body">
|
<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 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 class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
<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>
|
<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>
|
<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">
|
<div class="space-y-4">
|
||||||
@ -144,7 +144,7 @@
|
|||||||
{{ sendingMessage ? 'Sending...' : 'Send' }}
|
{{ sendingMessage ? 'Sending...' : 'Send' }}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@ -630,6 +630,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useMessageToast } from '@/composables/useMessageToast'
|
import { useMessageToast } from '@/composables/useMessageToast'
|
||||||
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const messageToast = useMessageToast()
|
const messageToast = useMessageToast()
|
||||||
@ -666,6 +667,13 @@ const connectedNodesCount = computed(() => peers.value.length)
|
|||||||
|
|
||||||
// Send Message modal
|
// Send Message modal
|
||||||
const showSendMessageModal = ref(false)
|
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 sendMessageTo = ref('')
|
||||||
const sendMessageText = ref('')
|
const sendMessageText = ref('')
|
||||||
const sendingMessage = ref(false)
|
const sendingMessage = ref(false)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
vue(),
|
vue(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favico.png', 'favico.svg', 'favicon.ico'],
|
includeAssets: ['assets/icon/favico-black.svg', 'favico.svg', 'favicon.ico'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Archipelago',
|
name: 'Archipelago',
|
||||||
short_name: 'Archipelago',
|
short_name: 'Archipelago',
|
||||||
@ -24,63 +24,63 @@ export default defineConfig({
|
|||||||
prefer_related_applications: false,
|
prefer_related_applications: false,
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '72x72',
|
sizes: '72x72',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '96x96',
|
sizes: '96x96',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '128x128',
|
sizes: '128x128',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '144x144',
|
sizes: '144x144',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '152x152',
|
sizes: '152x152',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '192x192',
|
sizes: '192x192',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '384x384',
|
sizes: '384x384',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any'
|
purpose: 'any'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '192x192',
|
sizes: '192x192',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'maskable'
|
purpose: 'maskable'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/img/favico.png',
|
src: '/assets/icon/favico-black.svg',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'maskable'
|
purpose: 'maskable'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -90,21 +90,21 @@ export default defineConfig({
|
|||||||
short_name: 'Dashboard',
|
short_name: 'Dashboard',
|
||||||
description: 'Open the dashboard',
|
description: 'Open the dashboard',
|
||||||
url: '/dashboard',
|
url: '/dashboard',
|
||||||
icons: [{ src: '/assets/img/favico.png', sizes: '192x192' }]
|
icons: [{ src: '/assets/icon/favico-black.svg', sizes: '192x192' }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'My Apps',
|
name: 'My Apps',
|
||||||
short_name: 'Apps',
|
short_name: 'Apps',
|
||||||
description: 'Manage your apps',
|
description: 'Manage your apps',
|
||||||
url: '/apps',
|
url: '/apps',
|
||||||
icons: [{ src: '/assets/img/favico.png', sizes: '192x192' }]
|
icons: [{ src: '/assets/icon/favico-black.svg', sizes: '192x192' }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'App Store',
|
name: 'App Store',
|
||||||
short_name: 'Store',
|
short_name: 'Store',
|
||||||
description: 'Browse and install apps',
|
description: 'Browse and install apps',
|
||||||
url: '/marketplace',
|
url: '/marketplace',
|
||||||
icons: [{ src: '/assets/img/favico.png', sizes: '192x192' }]
|
icons: [{ src: '/assets/icon/favico-black.svg', sizes: '192x192' }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -136,6 +136,10 @@ if [ "$LIVE" = true ]; then
|
|||||||
# Restart services
|
# Restart services
|
||||||
echo " Restarting services..."
|
echo " Restarting services..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
|
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)
|
# Rebuild and recreate LND UI container (port 8081 so Launch from UI and http://host:8081 both work)
|
||||||
echo " Rebuilding LND UI..."
|
echo " Rebuilding LND UI..."
|
||||||
@ -428,6 +432,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
echo "✅ Deployed to live system!"
|
echo "✅ Deployed to live system!"
|
||||||
echo " Backend: $(sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
|
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 " 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
|
else
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Build complete!"
|
echo "✅ Build complete!"
|
||||||
|
|||||||
98
scripts/setup-https-dev.sh
Normal file
98
scripts/setup-https-dev.sh
Normal 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)."
|
||||||
Loading…
x
Reference in New Issue
Block a user