mirror of https://github.com/requarks/wiki
parent
fbf0c1c689
commit
c10e988c3a
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<!--
|
||||
A tab hanging off the top edge of the viewport, mostly above it: all that shows at rest is a lip,
|
||||
enough to find and click without standing in front of the app. Pointing at it (or opening the
|
||||
menu) slides the whole thing into view.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="dev-tab w-unstyled font-robotomono"
|
||||
:class="{ 'is-open': state.menuShown }"
|
||||
aria-label="Developer tools"
|
||||
:aria-expanded="state.menuShown"
|
||||
@click="state.menuShown = !state.menuShown">
|
||||
dev
|
||||
<!--
|
||||
Controlled, rather than letting WMenu bind the trigger itself: the tab stays slid down for as
|
||||
long as the menu is open, which means this component has to know.
|
||||
-->
|
||||
<w-menu v-model="state.menuShown" anchor="bottom middle" self="top middle" :offset="[0, 4]">
|
||||
<w-list dense style="min-width: 260px">
|
||||
<w-item tag="label">
|
||||
<w-item-section>
|
||||
<w-item-label>Dark mode</w-item-label>
|
||||
<w-item-label caption>
|
||||
This session only. A reload restores the saved appearance.
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle v-model="isDark" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</w-menu>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
|
||||
/**
|
||||
* Developer quick menu — mounted only by a dev server, never built into a release. See the guard in
|
||||
* `App.vue`, which is what keeps this out of the production bundle entirely.
|
||||
*
|
||||
* Switches that belong here are the ones worth flipping while looking at a screen, without an
|
||||
* account, a setting, or a reload: throwaway state that the app should forget on its own. Add rows to
|
||||
* the list; the tab is deliberately narrow and dumb.
|
||||
*
|
||||
* Strings are hardcoded English rather than going through `t()`. Nothing in here should reach the
|
||||
* translators, and a dev-only key in `en.json` would be shipped to them on the next sync.
|
||||
*/
|
||||
|
||||
// DARK MODE
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
menuShown: false
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/*
|
||||
Writes straight to the body class through the composable, deliberately going around
|
||||
`userStore.appearance`: that one is persisted per user, and the point of this switch is to try the
|
||||
other theme on for a minute. Nothing saves it, so the next boot applies the stored appearance as
|
||||
usual -- as does anything that re-runs `applyTheme()` in App.vue, e.g. changing the real setting.
|
||||
*/
|
||||
const isDark = computed({
|
||||
get: () => dark.isActive,
|
||||
set: (value) => dark.set(value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dev-tab {
|
||||
position: fixed;
|
||||
top: -14px;
|
||||
left: 50%;
|
||||
/* -> Over everything the app itself can draw: notifications (9000), tooltips (7000), menus (6500+) */
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
height: 30px;
|
||||
align-items: flex-end;
|
||||
/* -> The label sits against the bottom edge, so it stays legible in the 16px lip that shows */
|
||||
padding: 0 12px 3px;
|
||||
border-radius: 0 0 5px 5px;
|
||||
background-color: #7c3aed;
|
||||
box-shadow: 0 1px 6px rgb(0 0 0 / 0.35);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transform: translateX(-50%);
|
||||
transition: top 0.15s var(--ease-standard);
|
||||
}
|
||||
|
||||
.dev-tab:hover,
|
||||
.dev-tab.is-open {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dev-tab {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,449 @@
|
||||
<template>
|
||||
<w-menu
|
||||
ref="menu"
|
||||
class="translucent-menu"
|
||||
:anchor="props.anchor"
|
||||
:self="props.self"
|
||||
:offset="props.offset"
|
||||
@show="onShow">
|
||||
<!-- -> A fixed width: the levels slide sideways past each other, so a panel that resized to its
|
||||
contents would jump mid-slide. Long titles truncate instead. -->
|
||||
<div class="browse-menu-panel">
|
||||
<div class="browse-menu-header flex flex-nowrap items-center">
|
||||
<!-- -> There is nowhere to go up to from the root, so the button is absent there rather than
|
||||
sitting disabled. It slides in from the left as its own space opens up, and back out
|
||||
the same way, so the title beside it moves with it instead of jumping. -->
|
||||
<transition name="browse-menu-up">
|
||||
<div v-if="!isRoot" class="browse-menu-up-slot">
|
||||
<!-- -> The icon comes through the slot rather than the `icon` prop, which is the only way
|
||||
to size it: WBtn draws a prop icon at WIcon's own default of 24px -->
|
||||
<w-btn
|
||||
class="browse-menu-up acrylic-btn"
|
||||
flat
|
||||
dense
|
||||
:disable="state.isLoading"
|
||||
:aria-label="t(`common.browse.upOneLevel`)"
|
||||
@click="goUp">
|
||||
<w-icon name="la:arrow-up" size="xs" />
|
||||
<w-tooltip>{{ t('common.browse.upOneLevel') }}</w-tooltip>
|
||||
</w-btn>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- -> The root has no title of its own, and the site is already named in the sidebar
|
||||
header directly above this, so there the path stands alone -->
|
||||
<div v-if="level.title" class="truncate text-sm font-medium">{{ level.title }}</div>
|
||||
<div class="text-caption truncate opacity-60 font-robotomono">/{{ state.path }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<w-separator />
|
||||
<div class="browse-menu-track relative overflow-hidden">
|
||||
<!-- -> Absolute, so that showing it neither shifts the rows down nor re-anchors the panel -->
|
||||
<w-linear-progress
|
||||
v-if="state.isLoading"
|
||||
class="absolute inset-x-0 top-0 z-10"
|
||||
indeterminate
|
||||
size="2px" />
|
||||
<transition :name="`browse-menu-${state.direction}`">
|
||||
<div :key="state.path" class="browse-menu-level py-1">
|
||||
<div
|
||||
v-for="item of level.items"
|
||||
:key="item.path"
|
||||
class="browse-menu-row flex flex-nowrap items-stretch">
|
||||
<!--
|
||||
One row per name, whichever of the two kinds it is -- and both at once for a page
|
||||
that also has a folder of pages under it. There the label opens the page and the
|
||||
chevron beside it descends, so neither way in hides the other.
|
||||
-->
|
||||
<router-link
|
||||
v-if="item.isPage"
|
||||
class="browse-menu-target"
|
||||
:to="`/${item.path}`"
|
||||
@click="menu?.hide()">
|
||||
<w-icon :name="item.icon || `la:file-alt`" size="xs" class="shrink-0 opacity-70" />
|
||||
<span class="truncate">{{ item.title }}</span>
|
||||
</router-link>
|
||||
<!-- -> The File Manager's folder, so a folder looks the same wherever the wiki draws
|
||||
one. Full strength, unlike the line icons around it: it is a colour image, and
|
||||
dimming it only washes the yellow out. -->
|
||||
<button v-else type="button" class="browse-menu-target" @click="descend(item)">
|
||||
<w-icon name="img:/_assets/icons/fluent-folder.svg" size="xs" class="shrink-0" />
|
||||
<span class="truncate">{{ item.title }}</span>
|
||||
<w-space />
|
||||
<w-icon name="la:angle-right" size="xs" class="shrink-0 opacity-40" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.isPage && item.isFolder"
|
||||
type="button"
|
||||
class="browse-menu-into"
|
||||
:aria-label="t(`common.browse.openFolder`, { title: item.title })"
|
||||
@click="descend(item)">
|
||||
<w-tooltip>{{ t('common.browse.openFolder', { title: item.title }) }}</w-tooltip>
|
||||
<w-icon name="la:angle-right" size="xs" class="opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="level.items.length < 1" class="browse-menu-note">
|
||||
{{ t('common.browse.empty') }}
|
||||
</div>
|
||||
<div v-if="level.truncated" class="browse-menu-note">
|
||||
{{ t('common.browse.truncated') }}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* The sidebar's Browse menu: one folder of the site at a time, as a reader walks it.
|
||||
*
|
||||
* Opens on the folder holding the page being read, and slides a level sideways on the way in or out
|
||||
* rather than nesting submenus -- a wiki tree is deep, and cascading panels would run off the screen
|
||||
* a couple of levels down.
|
||||
*
|
||||
* What it lists comes from `tree/browse`, which decides what a reader may see; nothing here filters,
|
||||
* so there is no version of this menu that shows more than the server was willing to hand over.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
anchor: {
|
||||
type: String,
|
||||
default: 'bottom left'
|
||||
},
|
||||
self: {
|
||||
type: String,
|
||||
default: 'top left'
|
||||
},
|
||||
offset: {
|
||||
type: Array,
|
||||
default: () => [0, 0]
|
||||
}
|
||||
})
|
||||
|
||||
// STORES
|
||||
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// REFS
|
||||
|
||||
const menu = ref(null)
|
||||
|
||||
// DATA
|
||||
|
||||
const EMPTY_LEVEL = { title: '', items: [], truncated: false }
|
||||
|
||||
const state = reactive({
|
||||
/** Slash-separated path of the folder being listed. Empty at the site root. */
|
||||
path: '',
|
||||
/** Which way the next level slides in from. */
|
||||
direction: 'forward',
|
||||
isLoading: false,
|
||||
/** Levels already fetched, by path, so that walking back up is instant. */
|
||||
levels: {}
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const level = computed(() => state.levels[state.path] ?? EMPTY_LEVEL)
|
||||
|
||||
const isRoot = computed(() => !state.path)
|
||||
|
||||
// METHODS
|
||||
|
||||
/** Opens on the folder holding the current page, with whatever was cached from last time dropped. */
|
||||
function onShow() {
|
||||
state.levels = {}
|
||||
state.direction = 'forward'
|
||||
state.path = pageStore.folderPath
|
||||
load(state.path)
|
||||
}
|
||||
|
||||
/*
|
||||
Which fetch is the current one. Reopening the menu drops the cache and asks again, so a request
|
||||
from the previous open can still land afterwards — and it must not be the one that decides the
|
||||
progress bar is finished. The level it writes is keyed by its own path, so it is harmless otherwise.
|
||||
*/
|
||||
let latestRequest = 0
|
||||
|
||||
async function load(path) {
|
||||
if (state.levels[path]) {
|
||||
return true
|
||||
}
|
||||
const request = ++latestRequest
|
||||
state.isLoading = true
|
||||
try {
|
||||
const data = await API_CLIENT.get(`sites/${siteStore.id}/tree/browse`, {
|
||||
searchParams: {
|
||||
path,
|
||||
locale: pageStore.locale
|
||||
}
|
||||
}).json()
|
||||
state.levels[path] = {
|
||||
title: data.title ?? '',
|
||||
items: data.items ?? [],
|
||||
truncated: Boolean(data.truncated)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.browse.loadFailed'),
|
||||
caption: err.message
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
if (request === latestRequest) {
|
||||
state.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to another level: the contents are fetched first, so the slide reveals the rows already in
|
||||
* place rather than an empty panel that fills in afterwards.
|
||||
*/
|
||||
async function moveTo(path, direction) {
|
||||
if (state.isLoading || path === state.path) {
|
||||
return
|
||||
}
|
||||
if (!(await load(path))) {
|
||||
return
|
||||
}
|
||||
state.direction = direction
|
||||
state.path = path
|
||||
// -> The panel is anchored by its top edge, and a level of a different length moves the other one
|
||||
await nextTick()
|
||||
menu.value?.updatePosition()
|
||||
}
|
||||
|
||||
function descend(item) {
|
||||
moveTo(item.path, 'forward')
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
moveTo(state.path.split('/').slice(0, -1).join('/'), 'back')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.browse-menu-panel {
|
||||
width: 270px;
|
||||
}
|
||||
|
||||
/*
|
||||
A fixed height, because the header's contents are not the same on every level: the root has no
|
||||
title line and no up button, and letting the row size itself moved everything below it by the
|
||||
difference each time a level changed. 52px is the two lines it holds at most -- a 20px title and a
|
||||
20px path -- plus the space around them.
|
||||
|
||||
That leaves 12px above and below the 28px button, which is where the 12px beside it comes from: the
|
||||
gap around it reads as even only if all four sides match. The left one is this padding, the right
|
||||
one is the slot's own margin below.
|
||||
*/
|
||||
.browse-menu-header {
|
||||
height: 52px;
|
||||
padding: 0 8px 0 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
A square target. `dense` puts 4px around the 18px icon, and pinning the min-width to the 28px the
|
||||
height comes to is what keeps the box from ending up wider or narrower than it is tall, rather than
|
||||
leaving the square to be a coincidence of two component defaults.
|
||||
*/
|
||||
.browse-menu-up {
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
/* -> Dimmed at rest: it is the one control up here, and it should not compete with the folder name
|
||||
beside it. Full strength once the pointer is on it. */
|
||||
.browse-menu-up .w-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.browse-menu-up:hover .w-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
The button's footprint, as its own element: `width` is what animates, so the space closes up with
|
||||
the button rather than after it. Sized to match the button, which fills it exactly, plus the gap
|
||||
that separates it from the title -- which belongs to the button and so goes when it goes.
|
||||
*/
|
||||
.browse-menu-up-slot {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
Clipped only while it moves. At rest the slot must not clip, or it would cut off the focus ring
|
||||
WBtn draws just outside its own box.
|
||||
*/
|
||||
.browse-menu-up-enter-active,
|
||||
.browse-menu-up-leave-active {
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 0.18s var(--ease-standard),
|
||||
margin-right 0.18s var(--ease-standard),
|
||||
opacity 0.18s var(--ease-standard);
|
||||
}
|
||||
|
||||
/* -> The slide itself is on the button: a percentage transform on the slot would resolve against a
|
||||
width that is zero at exactly that moment, and move nothing */
|
||||
.browse-menu-up-enter-active .browse-menu-up,
|
||||
.browse-menu-up-leave-active .browse-menu-up {
|
||||
transition: transform 0.18s var(--ease-standard);
|
||||
}
|
||||
|
||||
.browse-menu-up-enter-from,
|
||||
.browse-menu-up-leave-to {
|
||||
width: 0;
|
||||
margin-right: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.browse-menu-up-enter-from .browse-menu-up,
|
||||
.browse-menu-up-leave-to .browse-menu-up {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.browse-menu-up-enter-active,
|
||||
.browse-menu-up-leave-active,
|
||||
.browse-menu-up-enter-active .browse-menu-up,
|
||||
.browse-menu-up-leave-active .browse-menu-up {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
.browse-menu-target {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
The page being read, marked without a line of script: a `router-link` to the current route carries
|
||||
`router-link-exact-active` itself, and the menu opens on that page's own folder — so it is normally
|
||||
one of the rows on screen.
|
||||
*/
|
||||
.browse-menu-target.router-link-exact-active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
|
||||
@at-root .body--dark & {
|
||||
color: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.browse-menu-into {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
/* -> The seam that says the row has two hit targets rather than one */
|
||||
border-left: 1px solid rgb(0 0 0 / 0.08);
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-left-color: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* Same tints WItem uses, so a row here feels like a row anywhere else */
|
||||
.browse-menu-target,
|
||||
.browse-menu-into {
|
||||
&:hover {
|
||||
background-color: rgb(0 0 0 / 0.08);
|
||||
}
|
||||
&:active {
|
||||
background-color: rgb(0 0 0 / 0.14);
|
||||
}
|
||||
|
||||
@at-root .body--dark & {
|
||||
&:hover {
|
||||
background-color: rgb(255 255 255 / 0.14);
|
||||
}
|
||||
&:active {
|
||||
background-color: rgb(255 255 255 / 0.22);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.browse-menu-note {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/*
|
||||
The slide.
|
||||
|
||||
The incoming level stays in flow, so the panel takes its height immediately; the outgoing one is
|
||||
taken out of flow for the duration, which is what lets the two overlap while they cross.
|
||||
*/
|
||||
.browse-menu-level {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browse-menu-forward-enter-active,
|
||||
.browse-menu-forward-leave-active,
|
||||
.browse-menu-back-enter-active,
|
||||
.browse-menu-back-leave-active {
|
||||
transition:
|
||||
transform 0.18s var(--ease-standard),
|
||||
opacity 0.18s var(--ease-standard);
|
||||
}
|
||||
|
||||
.browse-menu-forward-leave-active,
|
||||
.browse-menu-back-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.browse-menu-forward-enter-from,
|
||||
.browse-menu-back-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.browse-menu-forward-leave-to,
|
||||
.browse-menu-back-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.browse-menu-forward-enter-active,
|
||||
.browse-menu-forward-leave-active,
|
||||
.browse-menu-back-enter-active,
|
||||
.browse-menu-back-leave-active {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card style="width: 450px; max-width: 90vw">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="la:lock" size="sm" class="mr-2" />
|
||||
<span>{{ t('common.page.unlockTitle') }}</span>
|
||||
</w-card-section>
|
||||
<w-form ref="unlockForm" class="py-2" @submit="unlock">
|
||||
<w-item>
|
||||
<blueprint-icon icon="key" />
|
||||
<w-item-section>
|
||||
<w-input
|
||||
ref="iptPassword"
|
||||
v-model="state.password"
|
||||
outlined
|
||||
dense
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
revealable
|
||||
hide-bottom-space
|
||||
:label="t(`auth.fields.password`)"
|
||||
:rules="passwordValidation"
|
||||
lazy-rules="ondemand" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-form>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
icon="la:lock-open"
|
||||
:label="t(`common.page.unlock`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:loading="state.isLoading"
|
||||
@click="unlock" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import { usePageStore } from '@/stores/page'
|
||||
|
||||
/**
|
||||
* Prompts for a password-protected page's password and asks the server to open it.
|
||||
*
|
||||
* Confirming resolves the dialog only once the server has accepted the password and the page store
|
||||
* holds the content — so whoever opened this can take `ok` to mean the page is readable, and a wrong
|
||||
* guess leaves the prompt up to try again.
|
||||
*/
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// REFS
|
||||
|
||||
const iptPassword = ref(null)
|
||||
const unlockForm = ref(null)
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
|
||||
autofocus: () => iptPassword.value
|
||||
})
|
||||
|
||||
// STORES
|
||||
|
||||
const pageStore = usePageStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
password: '',
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
// VALIDATION RULES
|
||||
|
||||
const passwordValidation = [(val) => val.length > 0 || t('auth.errors.missingPassword')]
|
||||
|
||||
// METHODS
|
||||
|
||||
async function unlock() {
|
||||
state.isLoading = true
|
||||
try {
|
||||
if (!(await unlockForm.value.validate(true))) {
|
||||
throw new Error(t('auth.errors.missingPassword'))
|
||||
}
|
||||
await pageStore.pageUnlock(state.password)
|
||||
onDialogOK()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
// -> A rejected password is the expected outcome here, not a failure to report as one
|
||||
message: err.response?.status === 401 ? t('common.page.lockedWrongPassword') : err.message
|
||||
})
|
||||
// -> Cleared and refocused, because the next thing a reader does is type it again
|
||||
state.password = ''
|
||||
iptPassword.value?.focus()
|
||||
}
|
||||
state.isLoading = false
|
||||
}
|
||||
</script>
|
||||
@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<w-menu
|
||||
auto-close
|
||||
anchor="bottom middle"
|
||||
self="top middle"
|
||||
@show="menuShown"
|
||||
@before-hide="menuHidden">
|
||||
<w-list dense padding>
|
||||
<w-item clickable ref="copyUrlButton">
|
||||
<w-item-section class="items-center" avatar>
|
||||
<w-icon color="grey" name="la:clipboard" size="sm" />
|
||||
</w-item-section>
|
||||
<w-item-section class="pr-4">Copy URL</w-item-section>
|
||||
</w-item>
|
||||
<w-item
|
||||
clickable
|
||||
tag="a"
|
||||
:href="`mailto:?subject=` + encodeURIComponent(props.title) + `&body=` + encodeURIComponent(urlFormatted) + `%0D%0A%0D%0A` + encodeURIComponent(props.description)"
|
||||
target="_blank">
|
||||
<w-item-section class="items-center" avatar>
|
||||
<w-icon color="grey" name="la:envelope" size="sm" />
|
||||
</w-item-section>
|
||||
<w-item-section class="pr-4">Email</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import ClipboardJS from 'clipboard'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Untitled Page'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
width: 626,
|
||||
height: 436,
|
||||
left: 0,
|
||||
top: 0,
|
||||
clip: null
|
||||
})
|
||||
let clip = null
|
||||
|
||||
const copyUrlButton = ref(null)
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const urlFormatted = computed(() => {
|
||||
if (!import.meta.env.SSR) {
|
||||
return props.url ? props.url : window.location.href
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
// METHODS
|
||||
|
||||
function openSocialPop(url) {
|
||||
const popupWindow = window.open(
|
||||
url,
|
||||
'sharer',
|
||||
`status=no,height=${state.height},width=${state.width},resizable=yes,left=${state.left},top=${state.top},screenX=${state.left},screenY=${state.top},toolbar=no,menubar=no,scrollbars=no,location=no,directories=no`
|
||||
)
|
||||
|
||||
popupWindow.focus()
|
||||
}
|
||||
|
||||
function menuShown(ev) {
|
||||
clip = new ClipboardJS(copyUrlButton.value.$el, {
|
||||
text: () => {
|
||||
return urlFormatted.value
|
||||
}
|
||||
})
|
||||
|
||||
clip.on('success', () => {
|
||||
notify({
|
||||
message: 'URL copied successfully',
|
||||
icon: 'la:clipboard'
|
||||
})
|
||||
})
|
||||
clip.on('error', () => {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: 'Failed to copy to clipboard'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function menuHidden(ev) {
|
||||
clip.destroy()
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
/**
|
||||
* Center the popup on dual screens
|
||||
* http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen/32261263
|
||||
*/
|
||||
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
|
||||
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
|
||||
|
||||
const width = window.innerWidth
|
||||
? window.innerWidth
|
||||
: document.documentElement.clientWidth
|
||||
? document.documentElement.clientWidth
|
||||
: screen.width
|
||||
const height = window.innerHeight
|
||||
? window.innerHeight
|
||||
: document.documentElement.clientHeight
|
||||
? document.documentElement.clientHeight
|
||||
: screen.height
|
||||
|
||||
state.left = width / 2 - state.width / 2 + dualScreenLeft
|
||||
state.top = height / 2 - state.height / 2 + dualScreenTop
|
||||
})
|
||||
</script>
|
||||
Loading…
Reference in new issue