add Vue UI [wip]

This commit is contained in:
ducklet 2021-07-25 19:01:25 +02:00
parent 3d5656392e
commit b47dfc579b
22 changed files with 2242 additions and 6 deletions

2
Procfile Normal file
View file

@ -0,0 +1,2 @@
server: ./run dev-server
ui: ./run dev-ui

View file

@ -4,4 +4,7 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
exec poetry export -o requirements.txt poetry export -o requirements.txt
cd unwind-ui
npm run build

View file

@ -4,4 +4,4 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
exec uvicorn unwind:create_app --factory --reload exec honcho start

7
scripts/dev-server Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
exec uvicorn unwind:create_app --factory --reload

7
scripts/dev-ui Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh -eu
cd "$RUN_DIR"/unwind-ui
[ -z "${DEBUG:-}" ] || set -x
exec npm run dev

View file

@ -1,8 +1,6 @@
#!/bin/sh -eu #!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
isort --profile black unwind "$RUN_BIN" lint-js "$@"
black unwind "$RUN_BIN" lint-py "$@"

21
scripts/lint-js Executable file
View file

@ -0,0 +1,21 @@
#!/bin/sh -eu
cd "$RUN_DIR"
if [ "${1:-}" = '--fix' ]; then
mode=fix
else
mode=check
fi
if [ "$mode" = 'fix' ]; then
prettier_opts=--write
else
prettier_opts=--check
fi
[ -z "${DEBUG:-}" ] || set -x
cd unwind-ui
npm run lint ||:
npx prettier $prettier_opts 'src/**/*.{js,ts,vue}'

8
scripts/lint-py Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
isort --profile black unwind
black unwind

2
unwind-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

12
unwind-ui/index.html Normal file
View 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

File diff suppressed because it is too large Load diff

26
unwind-ui/package.json Normal file
View 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
View 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>

View 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>

View 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
View file

@ -0,0 +1,3 @@
export default {
api_url: "http://localhost:8000/api/v1/",
}

12
unwind-ui/src/main.ts Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

14
unwind-ui/tsconfig.json Normal file
View 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
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})