unwind/unwind-ui/src/App.vue

269 lines
6.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<section class="section">
<div class="container">
<user-login
class="is-justify-content-end"
@login="(login) => (credentials = login)"
/>
<div class="field is-flex is-justify-content-end" v-if="login_user">
<div class="control has-icons-left">
<div class="select is-small">
<select v-model="filter_group" @change="search">
<option selected value="">Me ({{ login_user.name }})</option>
<option v-for="g in login_user.groups" :key="g.id" :value="g.id">
{{ g.id }}
</option>
</select>
</div>
<div class="icon is-small is-left">
<span class="fas fa-group">👥</span>
</div>
</div>
</div>
<div class="field has-addons mt-6">
<div class="control is-expanded">
<input
class="input is-loading"
type="text"
placeholder="Movie title"
v-model="query"
@change="search"
/>
</div>
<p class="control">
<span class="select">
<select v-model="media_type" @change="search">
<option value="">(any type)</option>
<option v-for="t in media_types" :key="t" :value="t">{{ t }}</option>
</select>
</span>
</p>
<div class="control">
<a class="button is-info" :disabled="!active"> Search </a>
</div>
</div>
<movie-list :items="items" @reachBottom="loadMore" />
<footer class="footer" v-if="is_end">
<p class="subtitle content has-text-centered is-italic"> fin </p>
</footer>
</div>
</section>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import config from "./config.ts"
import { is_object } from "./utils.ts"
const media_types = [
"Movie",
"Radio Episode",
"Radio Series",
"Short",
"TV Episode",
"TV Mini Series",
"TV Movie",
"TV Series",
"TV Short",
"TV Special",
"Video",
"Video Game",
]
function clean_obj(o) {
return Object.fromEntries(Object.entries(o).filter(([k, v]) => v))
}
function api_url(resource, params = []) {
const url = new URL(resource, config.api_url)
if (is_object(params)) {
params = clean_obj(params)
}
if (is_object(params) || params.length) {
url.search = new URLSearchParams(params)
}
return url
}
function set_view_params(params) {
history.replaceState(null, "", "#" + new URLSearchParams(clean_obj(params)))
}
function view_params() {
const fragment = (window.location.hash || "").replace(/^#/, "")
const params = new URLSearchParams(fragment)
return {
query: params.get("query") || "",
type: params.get("type") || "",
}
}
async function req(url, opts = {}, { user_id = "", secret = "" }, data = null) {
opts.headers = opts.headers || {}
if (data !== null) {
opts.body = JSON.stringify(data)
opts.headers["Content-Type"] = "application/json"
}
if (secret) {
const credentials = btoa(`${user_id}:${secret}`)
opts.headers["Authorization"] = `Basic ${credentials}`
opts.mode = "cors"
opts.credentials = "include"
}
const resp = await window.fetch(url, opts)
if (!resp.ok) {
throw resp
}
return await resp.json()
}
async function get(resource, params = [], user = {}) {
const url = api_url(resource, params)
const opts = {}
return await req(url, opts, user)
}
async function patch(resource, params = [], user = {}, data = null) {
const url = api_url(resource, params)
const opts = { method: "PATCH" }
return await req(url, opts, user, data)
}
function debounce_async(ms, func) {
let abort
return async (...args) => {
if (abort) {
abort()
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve(func(...args))
}, ms)
abort = () => {
reject("Function call aborted, replaced by a later call.")
clearTimeout(timer)
abort = null
}
})
}
}
const debounced_get = debounce_async(100, get)
type ULID = string
type User = {
id: ULID
name: string
groups: Array<{ id: ULID }>
}
function parse_query(query) {
const match = /^(.*?)(?:\s+([<=>])?(\d+|\(\d+\)))?$/.exec(query)
const title = match[1].replaceAll('"', "")
const comp = match[2] || ""
const year = match[3] ? /\d+/.exec(match[3])[0] : ""
return { title, year: year ? `${comp}${year}` : null }
}
export default defineComponent({
data: () => ({
query: "",
items: [],
credentials: { user_id: "", secret: "" },
login_user: null,
page: 1,
is_end: false,
media_types,
media_type: "",
active: false,
filter_group: "",
}),
mounted() {
const { query, type } = view_params()
this.query = query
this.media_type = media_types.includes(type) ? type : ""
},
methods: {
async search() {
this.active = true
set_view_params({ query: this.query, type: this.media_type })
if (!this.query) {
return
}
this.page = 1
this.is_end = false
this.items = []
await this.loadMore()
},
async loadMore() {
this.active = false
if (!this.query || this.is_end) {
return
}
const per_page = 100
const media_type = this.media_type === "" ? null : this.media_type
const user_id = (this.filter_group === "" && this.credentials.user_id) || null
const group_id = user_id === null && this.filter_group ? this.filter_group : null
const { title, year } = parse_query(this.query)
const more = await debounced_get(
"movies",
{
title,
year,
per_page,
include_unrated: true,
page: this.page,
media_type,
user_id,
group_id,
},
this.credentials,
)
this.items = this.items.concat(more)
if (more.length < per_page) {
this.is_end = true
} else {
this.page += 1
}
},
},
watch: {
async credentials(creds) {
if (!creds.user_id) {
return
}
const user: User = await get(`users/${creds.user_id}`, {}, creds)
this.login_user = user
},
},
})
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
margin-top: 60px;
}
</style>