<template> <aside id="sidebar" ref="sidebar"> <div class="scrollable-area"> <div class="logo-container"> <img :src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`" class="logo" alt="SuperNETs" role="presentation" /> <img :src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`" class="logo-inverted" alt="SuperNETs" role="presentation" /> <span v-if="isDevelopment" title="Hard Lounge has been built in development mode" :style="{ backgroundColor: '#ff9e18', color: '#000', padding: '2px', borderRadius: '4px', fontSize: '12px', }" >DEVELOPER</span > </div> <NetworkList /> </div> <footer id="footer"> <span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Connect to network" ><router-link v-slot:default="{navigate, isActive}" to="/connect" role="tab" aria-controls="connect" > <button :class="['icon', 'connect', {active: isActive}]" :aria-selected="isActive" @click="navigate" @keypress.enter="navigate" /> </router-link ></span> <span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings" ><router-link v-slot:default="{navigate, isActive}" to="/settings" role="tab" aria-controls="settings" > <button :class="['icon', 'settings', {active: isActive}]" :aria-selected="isActive" @click="navigate" @keypress.enter="navigate" ></button> </router-link ></span> <span class="tooltipped tooltipped-n tooltipped-no-touch" :aria-label=" store.state.serverConfiguration?.isUpdateAvailable ? 'Help\n(update available)' : 'Help' " ><router-link v-slot:default="{navigate, isActive}" to="/help" role="tab" aria-controls="help" > <button :aria-selected="route.name === 'Help'" :class="[ 'icon', 'help', {notified: store.state.serverConfiguration?.isUpdateAvailable}, {active: isActive}, ]" @click="navigate" @keypress.enter="navigate" ></button> </router-link ></span> </footer> </aside> </template> <script lang="ts"> import {defineComponent, nextTick, onMounted, onUnmounted, PropType, ref} from "vue"; import {useRoute} from "vue-router"; import {useStore} from "../js/store"; import NetworkList from "./NetworkList.vue"; export default defineComponent({ name: "Sidebar", components: { NetworkList, }, props: { overlay: {type: Object as PropType<HTMLElement | null>, required: true}, }, setup(props) { const isDevelopment = process.env.NODE_ENV !== "production"; const store = useStore(); const route = useRoute(); const touchStartPos = ref<Touch | null>(); const touchCurPos = ref<Touch | null>(); const touchStartTime = ref<number>(0); const menuWidth = ref<number>(0); const menuIsMoving = ref<boolean>(false); const menuIsAbsolute = ref<boolean>(false); const sidebar = ref<HTMLElement | null>(null); const toggle = (state: boolean) => { store.commit("sidebarOpen", state); }; const onTouchMove = (e: TouchEvent) => { const touch = (touchCurPos.value = e.touches.item(0)); if ( !touch || !touchStartPos.value || !touchStartPos.value.screenX || !touchStartPos.value.screenY ) { return; } let distX = touch.screenX - touchStartPos.value.screenX; const distY = touch.screenY - touchStartPos.value.screenY; if (!menuIsMoving.value) { // tan(45°) is 1. Gestures in 0°-45° (< 1) are considered horizontal, so // menu must be open; gestures in 45°-90° (>1) are considered vertical, so // chat windows must be scrolled. if (Math.abs(distY / distX) >= 1) { // eslint-disable-next-line no-use-before-define onTouchEnd(); return; } const devicePixelRatio = window.devicePixelRatio || 2; if (Math.abs(distX) > devicePixelRatio) { store.commit("sidebarDragging", true); menuIsMoving.value = true; } } // Do not animate the menu on desktop view if (!menuIsAbsolute.value) { return; } if (store.state.sidebarOpen) { distX += menuWidth.value; } if (distX > menuWidth.value) { distX = menuWidth.value; } else if (distX < 0) { distX = 0; } if (sidebar.value) { sidebar.value.style.transform = "translate3d(" + distX.toString() + "px, 0, 0)"; } if (props.overlay) { props.overlay.style.opacity = `${distX / menuWidth.value}`; } }; const onTouchEnd = () => { if (!touchStartPos.value?.screenX || !touchCurPos.value?.screenX) { return; } const diff = touchCurPos.value.screenX - touchStartPos.value.screenX; const absDiff = Math.abs(diff); if ( absDiff > menuWidth.value / 2 || (Date.now() - touchStartTime.value < 180 && absDiff > 50) ) { toggle(diff > 0); } document.body.removeEventListener("touchmove", onTouchMove); document.body.removeEventListener("touchend", onTouchEnd); store.commit("sidebarDragging", false); touchStartPos.value = null; touchCurPos.value = null; touchStartTime.value = 0; menuIsMoving.value = false; void nextTick(() => { if (sidebar.value) { sidebar.value.style.transform = ""; } if (props.overlay) { props.overlay.style.opacity = ""; } }); }; const onTouchStart = (e: TouchEvent) => { if (!sidebar.value) { return; } touchStartPos.value = touchCurPos.value = e.touches.item(0); if (e.touches.length !== 1) { onTouchEnd(); return; } const styles = window.getComputedStyle(sidebar.value); menuWidth.value = parseFloat(styles.width); menuIsAbsolute.value = styles.position === "absolute"; if ( !store.state.sidebarOpen || (touchStartPos.value?.screenX && touchStartPos.value.screenX > menuWidth.value) ) { touchStartTime.value = Date.now(); document.body.addEventListener("touchmove", onTouchMove, {passive: true}); document.body.addEventListener("touchend", onTouchEnd, {passive: true}); } }; onMounted(() => { document.body.addEventListener("touchstart", onTouchStart, {passive: true}); }); onUnmounted(() => { document.body.removeEventListener("touchstart", onTouchStart); }); const isPublic = () => document.body.classList.contains("public"); return { isDevelopment, store, route, sidebar, toggle, onTouchStart, onTouchMove, onTouchEnd, isPublic, }; }, }); </script>