b7f10eb092
The "Jump to" launcher only navigated for the internal tiles (Personal / Admin / Partner); every external app (Mail, Drev, Møder, …) just fired a toast and never opened. Hosts were also hardcoded to *.dezky.com, with Drev pointing at a vanity drev. subdomain instead of the real OCIS host. - Open external apps in a new tab at https://<host>.<baseDomain> - Derive the base domain from the portal's own hostname so links resolve in every environment (app.dezky.local → dezky.local, app.dezky.com → dezky.com) - Map Drev → files (OCIS); mail/meet/chat/cal/contacts/docs use their service subdomain
246 lines
7.1 KiB
Vue
246 lines
7.1 KiB
Vue
<script setup lang="ts">
|
|
// Waffle app launcher. Strict port of project/platform-app.jsx `AppLauncher`
|
|
// (lines 303-377). Right-aligned drop-in (margin 64px 20px 0 0, width 440),
|
|
// 3-col grid of centered tiles. The app you're currently inside is rendered
|
|
// with a signal-yellow icon container and a "HERE" pill.
|
|
|
|
import type { IconName } from './UiIcon.vue'
|
|
|
|
const launcher = useAppLauncher()
|
|
const route = useRoute()
|
|
const partnerMode = usePartnerMode()
|
|
const { isTenantAdmin } = useMe()
|
|
|
|
interface Tile {
|
|
key: string
|
|
name: string
|
|
icon: IconName
|
|
// Real per-service subdomain behind Traefik (e.g. 'files' for OCIS). Combined
|
|
// with the live base domain at click time to build the app URL. Omitted for
|
|
// internal tiles (Personal / Admin / Partner) which navigate in-app instead.
|
|
host?: string
|
|
current?: boolean
|
|
}
|
|
|
|
// Section context drives which extras (Admin / Partner) appear in the grid,
|
|
// and which tile is marked `current` ("HERE" pill).
|
|
const section = computed<'partner' | 'admin' | 'user'>(() => {
|
|
if (partnerMode.isActive.value) return 'admin'
|
|
if (route.path.startsWith('/partner')) return 'partner'
|
|
if (route.path.startsWith('/admin')) return 'admin'
|
|
return 'user'
|
|
})
|
|
|
|
const tiles = computed<Tile[]>(() => {
|
|
const isAdmin = section.value === 'admin'
|
|
const isPartner = section.value === 'partner'
|
|
const base: Tile[] = [
|
|
{ key: 'mail', name: 'Mail', icon: 'mail', host: 'mail' },
|
|
// Drev = OCIS files, served at files.<domain> (not a vanity 'drev' host).
|
|
{ key: 'drev', name: 'Drev', icon: 'folder', host: 'files' },
|
|
{ key: 'moder', name: 'Møder', icon: 'video', host: 'meet' },
|
|
{ key: 'chat', name: 'Chat', icon: 'chat', host: 'chat' },
|
|
{ key: 'cal', name: 'Kalender', icon: 'calendar', host: 'cal' },
|
|
{ key: 'contacts', name: 'Kontakter', icon: 'users', host: 'contacts' },
|
|
]
|
|
// Admin tile is the entry point to the workspace-admin surface. Show it to any
|
|
// tenant admin/owner (so they can get TO /admin from the personal shell), not
|
|
// only when already on the admin section. Marked "HERE" when on /admin. Pair it
|
|
// with a Personal tile so the launcher is a clean two-way toggle between the
|
|
// admin and personal surfaces — clicking either crosses over, "HERE" shows
|
|
// which side you're on.
|
|
if (isAdmin || isTenantAdmin.value) {
|
|
base.push({ key: 'home', name: 'Personal', icon: 'home', current: section.value === 'user' })
|
|
base.push({ key: 'admin', name: 'Admin', icon: 'shield', current: isAdmin && !isPartner })
|
|
}
|
|
if (isPartner) {
|
|
base.push({ key: 'partner', name: 'Partner', icon: 'briefcase', current: true })
|
|
}
|
|
base.push({ key: 'docs', name: 'Docs', icon: 'file', host: 'docs' })
|
|
return base
|
|
})
|
|
|
|
// The base domain the portal itself is served on, so app links resolve in every
|
|
// environment: app.dezky.local → dezky.local, app.dezky.com → dezky.com. Falls
|
|
// back to the dev domain during SSR (open() only runs client-side on click).
|
|
function appBaseDomain(): string {
|
|
if (import.meta.client) {
|
|
const parts = window.location.hostname.split('.')
|
|
return parts.length > 2 ? parts.slice(1).join('.') : window.location.hostname
|
|
}
|
|
return 'dezky.local'
|
|
}
|
|
|
|
const toast = useToast()
|
|
function open(t: Tile) {
|
|
launcher.hide()
|
|
if (t.key === 'home') return navigateTo('/')
|
|
if (t.key === 'admin') return navigateTo('/admin')
|
|
if (t.key === 'partner') return navigateTo('/partner')
|
|
if (t.host) {
|
|
const url = `https://${t.host}.${appBaseDomain()}`
|
|
window.open(url, '_blank', 'noopener')
|
|
toast.info(`Opening ${t.name}…`, url.replace(/^https:\/\//, ''))
|
|
return
|
|
}
|
|
toast.info(`Opening ${t.name}…`)
|
|
}
|
|
|
|
onMounted(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && launcher.open.value) launcher.hide()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="launcher">
|
|
<div v-if="launcher.open.value" class="scrim" @click="launcher.hide">
|
|
<div class="panel" @click.stop>
|
|
<header>
|
|
<div class="head-meta">
|
|
<Eyebrow>Apps</Eyebrow>
|
|
<div class="head-title">Jump to</div>
|
|
</div>
|
|
<button class="x" @click="launcher.hide" aria-label="Close">
|
|
<UiIcon name="x" :size="16" />
|
|
</button>
|
|
</header>
|
|
<div class="grid">
|
|
<a
|
|
v-for="t in tiles"
|
|
:key="t.key"
|
|
href="#"
|
|
class="tile"
|
|
@click.prevent="open(t)"
|
|
>
|
|
<span class="tile-icon" :class="{ current: t.current }">
|
|
<UiIcon :name="t.icon" :size="20" />
|
|
</span>
|
|
<span class="tile-name">{{ t.name }}</span>
|
|
<span v-if="t.current" class="here">HERE</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.scrim {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(10, 10, 10, 0.36);
|
|
z-index: 75;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.panel {
|
|
margin: 64px 20px 0 0;
|
|
width: 440px;
|
|
height: max-content;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
|
|
overflow: hidden;
|
|
}
|
|
|
|
header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.head-meta { display: flex; flex-direction: column; gap: 2px; }
|
|
.head-title {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
letter-spacing: -0.015em;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.x {
|
|
background: transparent;
|
|
border: 0;
|
|
padding: 6px;
|
|
border-radius: 4px;
|
|
color: var(--text-mute);
|
|
cursor: pointer;
|
|
}
|
|
.x:hover { background: var(--surface); color: var(--text); }
|
|
|
|
.grid {
|
|
padding: 12px;
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 4px;
|
|
}
|
|
|
|
.tile {
|
|
position: relative;
|
|
padding: 14px 8px;
|
|
border-radius: 6px;
|
|
text-decoration: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: var(--text);
|
|
transition: background 0.12s;
|
|
}
|
|
.tile:hover { background: var(--row-hover); }
|
|
|
|
.tile-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 10px;
|
|
background: var(--text);
|
|
color: var(--bg);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.tile-icon.current {
|
|
background: var(--accent);
|
|
color: var(--accent-fg);
|
|
}
|
|
|
|
.tile-name {
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
text-align: center;
|
|
}
|
|
|
|
.here {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
font-family: var(--font-mono);
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
color: var(--accent-fg);
|
|
background: var(--accent);
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.launcher-enter-active, .launcher-leave-active { transition: opacity 0.14s; }
|
|
.launcher-enter-from, .launcher-leave-to { opacity: 0; }
|
|
.launcher-enter-active .panel { animation: launcherIn 0.18s ease-out; }
|
|
|
|
@keyframes launcherIn {
|
|
from { opacity: 0; transform: translateY(-6px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|