feat: companion app improvements and intro overlay
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 39m1s
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 39m1s
Android: NES controller/keyboard enhancements, WebSocket reconnect, portrait mode. Backend: remote input handler updates. UI: companion intro overlay on dashboard, relay improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 4
|
||||
versionName = "0.4.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -32,6 +32,9 @@ class InputWebSocket(
|
||||
private var password: String = ""
|
||||
private var sessionCookie: String? = null
|
||||
|
||||
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
|
||||
var playerId: Int = 0
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val state: StateFlow<ConnectionState> = _state
|
||||
|
||||
@@ -109,10 +112,11 @@ class InputWebSocket(
|
||||
}
|
||||
|
||||
private fun doConnect() {
|
||||
val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else ""
|
||||
val wsUrl = serverUrl
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
.trimEnd('/') + "/ws/remote-input"
|
||||
.trimEnd('/') + basePath
|
||||
|
||||
val reqBuilder = Request.Builder().url(wsUrl)
|
||||
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
|
||||
@@ -160,7 +164,8 @@ class InputWebSocket(
|
||||
// ─── Input senders ──────────────────────────────────────────
|
||||
|
||||
fun sendKey(key: String) {
|
||||
ws?.send("""{"t":"k","k":"$key"}""")
|
||||
val pField = if (playerId > 0) ""","p":$playerId""" else ""
|
||||
ws?.send("""{"t":"k","k":"$key"$pField}""")
|
||||
}
|
||||
|
||||
fun sendMouseMove(dx: Int, dy: Int) {
|
||||
|
||||
@@ -101,8 +101,10 @@ fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) C
|
||||
@Composable
|
||||
fun NESController(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
playerId: Int = 0,
|
||||
onKey: (String) -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
onPlayerToggle: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
@@ -205,8 +207,15 @@ fun NESController(
|
||||
}
|
||||
}
|
||||
|
||||
// Settings button (bottom center)
|
||||
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
|
||||
// Player toggle + settings (bottom center)
|
||||
Row(
|
||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlayerPill(c, playerId, onPlayerToggle)
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,19 +379,39 @@ fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onCli
|
||||
}
|
||||
}
|
||||
|
||||
/** Small settings gear button */
|
||||
/** Settings gear button (48dp — large enough for easy tap on TV) */
|
||||
@Composable
|
||||
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(24.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (p) c.capsulePress else c.capsule)
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Settings, "Settings", Modifier.size(14.dp), tint = c.labelMuted)
|
||||
Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted)
|
||||
}
|
||||
}
|
||||
|
||||
/** Player ID toggle pill (P1/P2/ALL) */
|
||||
@Composable
|
||||
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||
val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" }
|
||||
val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted }
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(28.dp)
|
||||
.width(44.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (p) c.capsulePress else c.capsule)
|
||||
.border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,9 +55,15 @@ fun NESKeyboard(
|
||||
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
|
||||
var shifted by remember { mutableStateOf(false) }
|
||||
var capsLock by remember { mutableStateOf(false) }
|
||||
var ctrlHeld by remember { mutableStateOf(false) }
|
||||
val up = shifted || capsLock
|
||||
|
||||
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
|
||||
fun emit(k: String) {
|
||||
val key = if (ctrlHeld) "ctrl+$k" else k
|
||||
onKey(key)
|
||||
if (shifted && !capsLock) shifted = false
|
||||
if (ctrlHeld) ctrlHeld = false
|
||||
}
|
||||
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
|
||||
|
||||
// NES body wrapping keyboard
|
||||
@@ -113,9 +119,12 @@ fun NESKeyboard(
|
||||
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
|
||||
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
|
||||
}
|
||||
NKey(",", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("comma") }
|
||||
NKey("space", Modifier.weight(5f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
|
||||
NKey(".", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("period") }
|
||||
NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) {
|
||||
ctrlHeld = !ctrlHeld
|
||||
}
|
||||
NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") }
|
||||
NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
|
||||
NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") }
|
||||
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,13 @@ import com.archipelago.app.ui.theme.NES
|
||||
@Composable
|
||||
fun NESPortraitController(
|
||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||
playerId: Int = 0,
|
||||
onKey: (String) -> Unit,
|
||||
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
|
||||
onMouseClick: (Int) -> Unit = { _ -> },
|
||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||
onMenu: () -> Unit,
|
||||
onPlayerToggle: () -> Unit = {},
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
@@ -139,8 +141,16 @@ fun NESPortraitController(
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Settings
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
// Player toggle + Settings
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlayerPill(c, playerId, onPlayerToggle)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,14 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
|
||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||
|
||||
val ws = remember { InputWebSocket(scope) }
|
||||
|
||||
fun togglePlayer() {
|
||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||
ws.playerId = playerId
|
||||
}
|
||||
val connectionState by ws.state.collectAsState()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -98,32 +104,44 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
when {
|
||||
isGamepadMode && isLandscape -> NESController(
|
||||
style = controllerStyle,
|
||||
playerId = playerId,
|
||||
onKey = { ws.sendKey(it) },
|
||||
onMenu = { showModal = true },
|
||||
onPlayerToggle = ::togglePlayer,
|
||||
)
|
||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||
style = controllerStyle,
|
||||
playerId = playerId,
|
||||
onKey = { ws.sendKey(it) },
|
||||
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onMouseClick = { ws.sendClick(it) },
|
||||
onMouseScroll = { ws.sendScroll(it) },
|
||||
onMenu = { showModal = true },
|
||||
onPlayerToggle = ::togglePlayer,
|
||||
)
|
||||
else -> {
|
||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Trackpad(
|
||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onClick = { ws.sendClick(it) },
|
||||
onScroll = { ws.sendScroll(it) },
|
||||
onTwoFingerHold = { showModal = true },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
NESKeyboard(
|
||||
style = controllerStyle,
|
||||
onKey = { ws.sendKey(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Trackpad(
|
||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onClick = { ws.sendClick(it) },
|
||||
onScroll = { ws.sendScroll(it) },
|
||||
onTwoFingerHold = { showModal = true },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
NESKeyboard(
|
||||
style = controllerStyle,
|
||||
onKey = { ws.sendKey(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
// Settings icon top-right in keyboard mode
|
||||
com.archipelago.app.ui.components.SettingsBtn(
|
||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
onClick = { showModal = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,13 @@ fn validate_key(key: &str) -> bool {
|
||||
#[serde(tag = "t")]
|
||||
enum InputCommand {
|
||||
#[serde(rename = "k")]
|
||||
Key { k: String },
|
||||
Key {
|
||||
k: String,
|
||||
/// Optional player ID (1 or 2) for multi-player arcade games.
|
||||
/// When absent, input is broadcast without player tagging.
|
||||
#[serde(default)]
|
||||
p: Option<u8>,
|
||||
},
|
||||
#[serde(rename = "m")]
|
||||
MouseMove { x: i32, y: i32 },
|
||||
#[serde(rename = "c")]
|
||||
@@ -86,7 +92,7 @@ async fn handle_input(msg: &str) -> Result<Option<String>> {
|
||||
.context("invalid input command")?;
|
||||
|
||||
match cmd {
|
||||
InputCommand::Key { ref k } => {
|
||||
InputCommand::Key { ref k, .. } => {
|
||||
if !validate_key(k) {
|
||||
warn!("rejected key: {}", k);
|
||||
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
|
||||
@@ -124,6 +130,13 @@ impl ApiHandler {
|
||||
req: Request<hyper::Body>,
|
||||
relay_tx: broadcast::Sender<String>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||
let player_id: Option<u8> = req.uri().query()
|
||||
.and_then(|q| q.split('&').find(|s| s.starts_with("p=")))
|
||||
.and_then(|s| s.get(2..))
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&p: &u8| p == 1 || p == 2);
|
||||
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
@@ -185,8 +198,28 @@ impl ApiHandler {
|
||||
continue; // silently drop
|
||||
}
|
||||
|
||||
// Always relay to browser clients (remote browser sessions)
|
||||
let _ = relay_tx.send(text.clone());
|
||||
// Relay to browser clients. If this connection has a
|
||||
// player ID from query string and the message is a key
|
||||
// event without a player field, inject it so the browser
|
||||
// can route input to the correct player.
|
||||
let relay_text = if let Some(pid) = player_id {
|
||||
if text.contains(r#""t":"k""#) && !text.contains(r#""p":"#) {
|
||||
// Insert "p":N before the closing brace
|
||||
if let Some(pos) = text.rfind('}') {
|
||||
let mut tagged = text[..pos].to_string();
|
||||
tagged.push_str(&format!(r#","p":{}"#, pid));
|
||||
tagged.push('}');
|
||||
tagged
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
let _ = relay_tx.send(relay_text);
|
||||
|
||||
match handle_input(&text).await {
|
||||
Ok(Some(reply)) => {
|
||||
|
||||
@@ -99,7 +99,7 @@ function mapKey(xdotoolKey: string): string {
|
||||
}
|
||||
|
||||
function handleMessage(data: string) {
|
||||
let msg: { t: string; k?: string; x?: number; y?: number; b?: number }
|
||||
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
|
||||
try {
|
||||
msg = JSON.parse(data)
|
||||
} catch {
|
||||
@@ -114,6 +114,18 @@ function handleMessage(data: string) {
|
||||
case 'k': {
|
||||
if (!msg.k) break
|
||||
const key = mapKey(msg.k)
|
||||
// Dispatch player-tagged event for arcade/game apps (iframe postMessage or direct listeners)
|
||||
const player = msg.p ?? 0 // 0 = untagged/broadcast, 1 = P1, 2 = P2
|
||||
document.dispatchEvent(new CustomEvent('arcade-input', {
|
||||
detail: { key, player, type: 'down' },
|
||||
bubbles: true,
|
||||
}))
|
||||
// Also post to any iframe that might be listening (containerized apps like BotFights)
|
||||
const iframe = document.querySelector('iframe') as HTMLIFrameElement | null
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
|
||||
}
|
||||
// Keep existing keydown/keyup for backward compat with non-arcade UI navigation
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
|
||||
break
|
||||
|
||||
98
neode-ui/src/components/CompanionIntroOverlay.vue
Normal file
98
neode-ui/src/components/CompanionIntroOverlay.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="overlay-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 flex items-end sm:items-center justify-center p-4 z-[3000]"
|
||||
@click.self="dismiss"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex justify-center mb-3">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-white text-center mb-2">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 text-center mb-4 leading-relaxed">
|
||||
Control your node from another device. Install the
|
||||
<span class="text-orange-400 font-medium">Archipelago</span>
|
||||
companion app on your phone, connect to the same network, and use it as a
|
||||
gamepad or keyboard.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2 text-xs text-white/40">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">1</span>
|
||||
<span>Install the APK on your phone</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">2</span>
|
||||
<span>Enter your node address and password</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">3</span>
|
||||
<span>Use D-pad & buttons or keyboard to control apps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-4 w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30
|
||||
text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors"
|
||||
@click="dismiss"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
|
||||
const visible = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== '1') {
|
||||
// Delay slightly so it doesn't compete with login animation
|
||||
setTimeout(() => { visible.value = true }, 5000)
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, '1')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay-fade-enter-active { transition: opacity 0.3s ease; }
|
||||
.overlay-fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.overlay-fade-enter-from,
|
||||
.overlay-fade-leave-to { opacity: 0; }
|
||||
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
|
||||
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
|
||||
</style>
|
||||
@@ -121,6 +121,9 @@
|
||||
|
||||
<!-- Health Notifications Toast -->
|
||||
<HealthNotifications />
|
||||
|
||||
<!-- First-use companion intro overlay -->
|
||||
<CompanionIntroOverlay />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -137,6 +140,7 @@ import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
|
||||
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
|
||||
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||
import '@/views/dashboard/dashboard-styles.css'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user