tify
Version:
A slim and mobile-friendly IIIF document viewer
408 lines (354 loc) • 8.81 kB
HTML
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TIFY</title>
<style>
:root {
--base-color: #06b;
--base-color-lighter: #e6f0f8;
--border-color: #0003;
--button-hover-bg: #0058a2;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
font: inherit;
}
[v-cloak] {
display: none ;
}
html {
background: #666;
color: #333;
font-size: 24px;
}
body {
font: 16px/1rem sans-serif;
margin: 0;
}
.badge {
background: var(--base-color);
float: left;
border-radius: 2px;
color: #fff;
font-size: .618em;
font-weight: bold;
line-height: 1;
min-width: .75rem;
padding: .2em;
text-transform: uppercase;
text-align: center;
margin: .2rem .4em 0 0;
}
.bold {
font-weight: bold;
}
.button {
background: var(--base-color) linear-gradient(#fff2, #fff0);
border: 0;
border-radius: 2px;
color: #fff;
cursor: pointer;
font: inherit;
font-size: .8125em;
font-weight: bold;
margin: .125rem;
min-width: 1.25rem;
padding: .125rem .5rem;
white-space: nowrap;
}
.button:focus,
.button:not(:disabled):hover {
background: var(--base-color) linear-gradient(#fff4, #fff2);
}
.button:not(:disabled):active {
box-shadow: 0 .5px 3px #0004 inset, 0 0 0 1px #0002 inset;
}
.button:disabled {
opacity: .5;
}
.container {
height: 100vh;
}
.header + .container {
height: calc(100vh - 1.75rem);
}
.form {
display: flex;
align-items: center;
padding: .125rem;
width: 100%;
}
.header {
background: #fff;
backdrop-filter: blur(1px);
display: flex;
position: relative;
z-index: 10;
box-shadow: 0 -1px var(--border-color) inset;
}
.input {
background: #fff;
border: 1px solid transparent;
border-radius: 2px;
color: inherit;
flex: 1;
font: inherit;
margin: .125rem;
min-width: 0;
padding: calc(.125rem - 1px) calc(.25rem - 1px);
}
.input:focus {
border-color: var(--base-color);
outline: 2px solid var(--base-color-lighter);
}
.instances {
display: flex;
flex-wrap: wrap;
}
.instance {
box-shadow: -1px 0 var(--border-color), 0 -1px var(--border-color);
flex: 1;
position: relative;
z-index: 1;
}
.link {
color: var(--base-color);
text-decoration: none;
}
.link:focus,
.link:hover {
text-decoration: underline;
}
.menu {
position: relative;
}
.menu-dropdown {
background: #fff;
border-radius: 2px;
display: block;
filter: drop-shadow(0 0 .5rem #0006);
list-style: none;
min-width: 5rem;
opacity: 1;
padding: .5rem .75rem;
position: absolute;
right: .125rem;
top: calc(100% + .125rem);
transition: all .2s;
white-space: nowrap;
}
.menu-dropdown[hidden] {
opacity: 0;
top: calc(100% - .125rem);
visibility: hidden;
}
.menu-dropdown::before {
bottom: 100%;
content: '';
border: .25rem solid;
border-color: transparent transparent #fff;
position: absolute;
right: .375rem;
}
.menu-list {
list-style: none;
}
.menu-list + .menu-list {
box-shadow: 0 1px var(--border-color) inset;
margin-top: .5rem;
padding-top: .5rem;
}
</style>
</head>
<body v-scope @vue:mounted="mounted">
<main class="instances">
<section
v-for="instance in instances"
class="instance"
:key="instance.id"
>
<header
v-if="!instance.contentStateActive"
v-cloak
class="header"
>
<form class="form" @submit.prevent="instance.loadManifest()">
<input
v-model="instance.manifestUrl"
type="url"
:id="`manifest${instance.id}`"
class="input"
aria-label="IIIF manifest URL"
placeholder="IIIF manifest URL"
@focus="event.target.select()"
>
<button
type="submit"
class="button"
:disabled="!instance.manifestUrl"
>
Load
</button>
<div class="menu" @click.stop>
<button
type="button"
class="button"
aria-label="Toggle instance and language menu"
:aria-controls="`dropdown${instance.id}`"
:aria-expanded="instance.dropdownOpen"
@click="instance.dropdownOpen = !instance.dropdownOpen"
>
⋮
</button>
<div
v-cloak
:id="`dropdown${instance.id}`"
class="menu-dropdown"
:hidden="!instance.dropdownOpen"
>
<ul class="menu-list">
<li>
<a
class="link"
href="javascript:;"
@click="addInstance()"
>
Add instance
</a>
</li>
<li v-if="instances.length > 1">
<a
class="link"
href="javascript:;"
@click="instance.remove()"
>
Remove instance
</a>
</li>
</ul>
<ul class="menu-list">
<li
v-for="name, code in languages"
:key="code"
:class="{ 'bold': code === instance.language }"
>
<a
class="link"
href="javascript:;"
@click="instance.setLanguage(code)"
>
<span class="badge">{{ code }}</span>
{{ name }}
</a>
</li>
</ul>
</div>
</div>
</form>
</header>
<div :id="`container${instance.id}`" class="container"></div>
</section>
</main>
<script type="module" src="/src/main.js"></script>
<script>
/* global PetiteVue, Tify */
$VITE_PETITE_VUE; // eslint-disable-line
const instances = PetiteVue.reactive([]);
class Instance {
constructor(options = {}) {
this.id = options.id || '';
while (instances.find((instance) => instance.id === this.id)) {
this.id = ((parseInt(this.id, 10) || 1) + 1).toString();
}
this.contentStateActive = (new URL(window.location)).searchParams.get('iiif-content');
this.dropdownOpen = false;
this.language = options.language || 'en';
this.manifestUrl = options.manifestUrl || '';
this.tify = null;
instances.push(this);
}
remove() {
this.tify?.destroy();
instances.splice(instances.findIndex((instance) => instance.id === this.id), 1);
const url = new URL(window.location);
url.searchParams.delete(`language${this.id}`);
url.searchParams.delete(`manifest${this.id}`);
url.searchParams.delete(`tify${this.id}`);
window.history.pushState(null, '', url.toString());
}
loadManifest() {
this.tify?.destroy();
const url = new URL(window.location);
// Update URL query if manifest was changed via form input
if (!this.contentStateActive
&& url.searchParams.get(`manifest${this.id}`) !== this.manifestUrl
) {
url.searchParams.delete(`tify${this.id}`);
url.searchParams.set(`manifest${this.id}`, this.manifestUrl);
window.history.pushState(null, '', url.toString());
}
this.tify = new Tify({
container: document.getElementById(`container${this.id}`),
contentStateEnabled: this.contentStateActive,
manifestUrl: this.manifestUrl,
translationsDirUrl: 'translations',
language: this.language,
urlQueryKey: `tify${this.id}`,
});
// Expose latest instance for e2e tests
window.tify = this.tify;
}
setLanguage(code) {
this.language = code;
this.tify?.setLanguage(code);
const url = new URL(window.location);
if (code === 'en') {
url.searchParams.delete(`language${this.id}`);
} else {
url.searchParams.set(`language${this.id}`, code);
}
window.history.pushState(null, '', url.toString());
}
closeDropdown() {
this.dropdownOpen = false;
}
}
const app = PetiteVue.createApp({
instances,
languages: $VITE_LANGUAGES, // eslint-disable-line
mounted() {
// Restore state from URL query
const params = (new URL(window.location)).searchParams;
params.forEach((url, key) => {
if (!key.startsWith('manifest') && key !== 'iiif-content') {
return;
}
const id = key === 'iiif-content' ? '' : key.replace('manifest', '');
const instance = new Instance({
id,
language: params.get(`language${id}`),
manifestUrl: key === 'iiif-content' ? null : url,
urlQueryKey: `tify${id}`,
});
this.$nextTick(() => {
instance.loadManifest();
});
});
if (!instances.length) {
new Instance(); // eslint-disable-line no-new
}
document.addEventListener('click', () => instances.forEach((instance) => instance.closeDropdown()));
},
addInstance() {
new Instance(); // eslint-disable-line no-new
},
});
window.addEventListener('load', () => app.mount('body'));
</script>
</body>