add Vue UI [wip]
This commit is contained in:
parent
3d5656392e
commit
b47dfc579b
22 changed files with 2242 additions and 6 deletions
2
unwind-ui/.gitignore
vendored
Normal file
2
unwind-ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
dist
|
||||
12
unwind-ui/index.html
Normal file
12
unwind-ui/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>🧶 Unwind</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1698
unwind-ui/package-lock.json
generated
Normal file
1698
unwind-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
unwind-ui/package.json
Normal file
26
unwind-ui/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "vue-tsc --noEmit",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bulma": "^0.9.3",
|
||||
"vue": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^1.2.5",
|
||||
"@vue/compiler-sfc": "^3.0.5",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^2.4.2",
|
||||
"vue-tsc": "^0.0.24"
|
||||
},
|
||||
"prettier": {
|
||||
"arrowParens": "always",
|
||||
"printWidth": 88,
|
||||
"trailingComma": "all",
|
||||
"semi": false
|
||||
}
|
||||
}
|
||||
219
unwind-ui/src/App.vue
Normal file
219
unwind-ui/src/App.vue
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<template>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<user-login
|
||||
class="is-justify-content-end mb-6"
|
||||
@login="(login) => (user = login)"
|
||||
/>
|
||||
|
||||
<div class="field has-addons">
|
||||
<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" :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) {
|
||||
opts.headers["Authorization"] = `Bearer ${user_id} ${secret}`
|
||||
opts.mode = "cors"
|
||||
opts.credentials = "include"
|
||||
}
|
||||
|
||||
const resp = await window.fetch(url, opts)
|
||||
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)
|
||||
|
||||
export default defineComponent({
|
||||
data: () => ({
|
||||
query: "",
|
||||
items: [],
|
||||
user: { user_id: "", secret: "" },
|
||||
page: 1,
|
||||
is_end: false,
|
||||
media_types,
|
||||
media_type: "",
|
||||
active: false,
|
||||
}),
|
||||
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 more = await debounced_get(
|
||||
"movies",
|
||||
{
|
||||
title: this.query,
|
||||
per_page,
|
||||
include_unrated: true,
|
||||
page: this.page,
|
||||
media_type,
|
||||
user_id: this.user.user_id,
|
||||
},
|
||||
this.user,
|
||||
)
|
||||
this.items = this.items.concat(more)
|
||||
|
||||
if (more.length < per_page) {
|
||||
this.is_end = true
|
||||
} else {
|
||||
this.page += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</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>
|
||||
114
unwind-ui/src/components/MovieList.vue
Normal file
114
unwind-ui/src/components/MovieList.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<table class="table is-fullwidth is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>title</th>
|
||||
<th>year</th>
|
||||
<th>type</th>
|
||||
<th>rating</th>
|
||||
<th>runtime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :data-unwind-id="item.id">
|
||||
<td v-if="item.original_title">
|
||||
<a :href="`https://www.imdb.com/title/${item.imdb_id}/`">
|
||||
<span>{{ item.original_title }}</span>
|
||||
</a>
|
||||
<span v-if="item.title != item.original_title"> ({{ item.title }})</span>
|
||||
</td>
|
||||
<td v-else>
|
||||
<a :href="`https://www.imdb.com/title/${item.imdb_id}/`">
|
||||
<span>{{ item.title }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="year">{{ item.release_year }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mediatype">{{ item.media_type }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="score imdb-score tag is-large" title="IMDb rating (1-10)">{{ imdb_rating(item.imdb_score) }}</span>
|
||||
<span class="score tag is-info is-large" title="User rating (1-10)">{{ imdb_rating(item.user_score) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ duration(item.runtime) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div ref="sentinel"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
function imdb_rating(score) {
|
||||
if (score == null) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
const deci = Math.round((score * 9) / 10 + 10)
|
||||
return `${(deci / 10) | 0}.${deci % 10}`
|
||||
}
|
||||
|
||||
function duration(minutes_total) {
|
||||
if (minutes_total == null) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
const m = minutes_total % 60
|
||||
const h = (minutes_total / 60) | 0
|
||||
return `${h} h ${m} m`
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["reach-bottom"],
|
||||
mounted() {
|
||||
const options = {
|
||||
rootMargin: "500px",
|
||||
}
|
||||
const observer = new IntersectionObserver(([e]) => {
|
||||
if (!e.isIntersecting) {
|
||||
return
|
||||
}
|
||||
this.$emit("reach-bottom")
|
||||
}, options)
|
||||
observer.observe(this.$refs.sentinel)
|
||||
},
|
||||
methods: {
|
||||
imdb_rating,
|
||||
duration,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.year {
|
||||
color: grey;
|
||||
}
|
||||
.mediatype {
|
||||
color: grey;
|
||||
}
|
||||
.score {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
.score + .score {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.imdb-score {
|
||||
background-color: rgb(245, 197, 24);
|
||||
}
|
||||
.user-score {
|
||||
|
||||
}
|
||||
</style>
|
||||
73
unwind-ui/src/components/UserLogin.vue
Normal file
73
unwind-ui/src/components/UserLogin.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input is-small"
|
||||
type="text"
|
||||
placeholder="User ID"
|
||||
v-model="user_id"
|
||||
@change="active = true"
|
||||
/>
|
||||
<span class="icon is-small is-left">
|
||||
<i class="fas fa-user"></i>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input is-small"
|
||||
type="password"
|
||||
placeholder="Secret"
|
||||
v-model="secret"
|
||||
@change="active = true"
|
||||
/>
|
||||
<span class="icon is-small is-left">
|
||||
<i class="fas fa-lock"></i>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button
|
||||
class="button is-primary is-small"
|
||||
:disabled="!active"
|
||||
@click="login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
data: () => ({
|
||||
user_id: window.localStorage.user_id || "",
|
||||
secret: window.localStorage.secret || "",
|
||||
active: true,
|
||||
}),
|
||||
emits: ["login"],
|
||||
mounted() {
|
||||
const { user_id, secret } = this
|
||||
if (user_id && secret) {
|
||||
this.login()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
login() {
|
||||
const { user_id, secret } = this
|
||||
this.$emit("login", { user_id, secret })
|
||||
this.active = false
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
user_id(newval) {
|
||||
window.localStorage.user_id = newval
|
||||
},
|
||||
secret(newval) {
|
||||
window.localStorage.secret = newval
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
3
unwind-ui/src/config.ts
Normal file
3
unwind-ui/src/config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
api_url: "http://localhost:8000/api/v1/",
|
||||
}
|
||||
12
unwind-ui/src/main.ts
Normal file
12
unwind-ui/src/main.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { createApp } from "vue"
|
||||
import App from "./App.vue"
|
||||
|
||||
import MovieList from "./components/MovieList.vue"
|
||||
import UserLogin from "./components/UserLogin.vue"
|
||||
|
||||
import "bulma/css/bulma.css"
|
||||
|
||||
const app = createApp(App)
|
||||
app.component("movie-list", MovieList)
|
||||
app.component("user-login", UserLogin)
|
||||
app.mount("#app")
|
||||
6
unwind-ui/src/shims-vue.d.ts
vendored
Normal file
6
unwind-ui/src/shims-vue.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
declare module "*.vue" {
|
||||
import { DefineComponent } from "vue"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
3
unwind-ui/src/utils.ts
Normal file
3
unwind-ui/src/utils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function is_object(x) {
|
||||
return x !== null && typeof x === "object" && !Array.isArray(x)
|
||||
}
|
||||
1
unwind-ui/src/vite-env.d.ts
vendored
Normal file
1
unwind-ui/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
14
unwind-ui/tsconfig.json
Normal file
14
unwind-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"strict": false,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["esnext", "dom"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
unwind-ui/vite.config.ts
Normal file
7
unwind-ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()]
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue