Merge pull request #3 from wuyudi/addts

some improvement.
This commit is contained in:
依云 2022-06-18 15:54:44 +08:00 committed by GitHub
commit 326065950d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1528 additions and 687 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
[{package.json,.travis.yml,.eslintrc.json}]
indent_style = space

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
/public/build/ /public/build/
.DS_Store .DS_Store
.vscode

1300
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,17 +5,24 @@
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"dev": "rollup -c -w", "dev": "rollup -c -w",
"start": "sirv public --no-clear" "start": "sirv public --no-clear",
"check": "svelte-check --tsconfig ./tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0", "@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.0", "@rollup/plugin-node-resolve": "^11.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"@tsconfig/svelte": "^2.0.1",
"rollup": "^2.3.4", "rollup": "^2.3.4",
"rollup-plugin-css-only": "^3.1.0", "rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0", "rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.0.0", "rollup-plugin-svelte": "^7.0.0",
"rollup-plugin-terser": "^7.0.0", "rollup-plugin-terser": "^7.0.0",
"svelte": "^3.0.0" "svelte": "^3.0.0",
"svelte-check": "^2.0.0",
"svelte-preprocess": "^4.0.0",
"tslib": "^2.0.0",
"typescript": "^4.0.0"
}, },
"dependencies": { "dependencies": {
"sirv-cli": "^1.0.0" "sirv-cli": "^1.0.0"

View File

@ -1,77 +0,0 @@
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import livereload from 'rollup-plugin-livereload';
import { terser } from 'rollup-plugin-terser';
import css from 'rollup-plugin-css-only';
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
// stdio: ['ignore', 'inherit', 'inherit'],
// shell: true
});
process.on('SIGTERM', toExit);
process.on('exit', toExit);
}
};
}
export default {
input: 'src/main.js',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js'
},
plugins: [
svelte({
compilerOptions: {
// enable run-time checks when not in production
dev: !production
}
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: 'bundle.css' }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload('public'),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
buildDelay: 500,
clearScreen: false
}
};

89
rollup.config.ts Normal file
View File

@ -0,0 +1,89 @@
import svelte from "rollup-plugin-svelte";
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import livereload from "rollup-plugin-livereload";
import { terser } from "rollup-plugin-terser";
import sveltePreprocess from "svelte-preprocess";
import typescript from "@rollup/plugin-typescript";
import css from "rollup-plugin-css-only";
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = require("child_process").spawn(
"npm",
["run", "start", "--", "--dev"],
{
// stdio: ['ignore', 'inherit', 'inherit'],
// shell: true
}
);
process.on("SIGTERM", toExit);
process.on("exit", toExit);
},
};
}
export default {
input: "src/main.ts",
output: {
sourcemap: true,
format: "iife",
name: "app",
file: "public/build/bundle.js",
},
plugins: [
svelte({
preprocess: sveltePreprocess({ sourceMap: !production }),
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
},
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: "bundle.css" }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ["svelte"],
}),
commonjs(),
typescript({
sourceMap: true,
//sourceMap: !production,
inlineSources: !production,
}),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload("public"),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
],
watch: {
buildDelay: 500,
clearScreen: false,
},
};

View File

@ -1,121 +0,0 @@
// @ts-check
/** This script modifies the project to support TS code in .svelte files like:
<script lang="ts">
export let name: string;
</script>
As well as validating the code for CI.
*/
/** To work on this script:
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
*/
const fs = require("fs")
const path = require("path")
const { argv } = require("process")
const projectRoot = argv[2] || path.join(__dirname, "..")
// Add deps to pkg.json
const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"))
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
"svelte-check": "^2.0.0",
"svelte-preprocess": "^4.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"typescript": "^4.0.0",
"tslib": "^2.0.0",
"@tsconfig/svelte": "^2.0.0"
})
// Add script for checking
packageJSON.scripts = Object.assign(packageJSON.scripts, {
"check": "svelte-check --tsconfig ./tsconfig.json"
})
// Write the package JSON
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " "))
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
const beforeMainJSPath = path.join(projectRoot, "src", "main.js")
const afterMainTSPath = path.join(projectRoot, "src", "main.ts")
fs.renameSync(beforeMainJSPath, afterMainTSPath)
// Switch the app.svelte file to use TS
const appSveltePath = path.join(projectRoot, "src", "App.svelte")
let appFile = fs.readFileSync(appSveltePath, "utf8")
appFile = appFile.replace("<script>", '<script lang="ts">')
appFile = appFile.replace("export let name;", 'export let name: string;')
fs.writeFileSync(appSveltePath, appFile)
// Edit rollup config
const rollupConfigPath = path.join(projectRoot, "rollup.config.js")
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")
// Edit imports
rollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';`)
// Replace name of entry point
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)
// Add preprocessor
rollupConfig = rollupConfig.replace(
'compilerOptions:',
'preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:'
);
// Add TypeScript
rollupConfig = rollupConfig.replace(
'commonjs(),',
'commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),'
);
fs.writeFileSync(rollupConfigPath, rollupConfig)
// Add TSConfig
const tsconfig = `{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}`
const tsconfigPath = path.join(projectRoot, "tsconfig.json")
fs.writeFileSync(tsconfigPath, tsconfig)
// Add global.d.ts
const dtsPath = path.join(projectRoot, "src", "global.d.ts")
fs.writeFileSync(dtsPath, `/// <reference types="svelte" />`)
// Delete this script, but not during testing
if (!argv[2]) {
// Remove the script
fs.unlinkSync(path.join(__filename))
// Check for Mac's DS_store file, and if it's the only one left remove it
const remainingFiles = fs.readdirSync(path.join(__dirname))
if (remainingFiles.length === 1 && remainingFiles[0] === '.DS_store') {
fs.unlinkSync(path.join(__dirname, '.DS_store'))
}
// Check if the scripts folder is empty
if (fs.readdirSync(path.join(__dirname)).length === 0) {
// Remove the scripts folder
fs.rmdirSync(path.join(__dirname))
}
}
// Adds the extension recommendation
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true })
fs.writeFileSync(path.join(projectRoot, ".vscode", "extensions.json"), `{
"recommendations": ["svelte.svelte-vscode"]
}
`)
console.log("Converted to TypeScript.")
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
console.log("\nYou will need to re-run your dependency manager to get started.")
}

View File

@ -1,169 +1,175 @@
<script> <script lang="ts">
import { onMount, setContext } from 'svelte' import { onMount, setContext } from "svelte";
import Message from './Message.svelte' import Message from "./Message.svelte";
import Name from './Name.svelte' import Name from "./Name.svelte";
import { sleep } from './util.js' import { sleep } from "./util.js";
const LUOXU_URL = 'https://lab.lilydjwg.me/luoxu' const LUOXU_URL = "https://lab.lilydjwg.me/luoxu";
const islocal = LUOXU_URL.indexOf('http://localhost') === 0 const islocal = LUOXU_URL.startsWith("http://localhost");
let groups = [] let groups = [];
let group let group: string;
let query let query: string;
let error let error: string;
let result let result: { messages: string | any[]; has_more: any; groupinfo: any };
let now = new Date() let now = new Date();
let loading = false let loading = false;
let need_update_title = false let need_update_title = false;
let sender let sender: string;
let selected_init let selected_init: string;
let our_hash_change = false let our_hash_change = false;
setContext('LUOXU_URL', LUOXU_URL) setContext("LUOXU_URL", LUOXU_URL);
function parse_hash() { function parse_hash() {
const hash = location.hash const hash = location.hash;
if (hash) { if (hash) {
return new URLSearchParams(hash.substring(1)) return new URLSearchParams(hash.substring(1));
} }
} }
onMount(async () => { onMount(async () => {
do_hash_search() do_hash_search();
while (true) { while (true) {
try { try {
const res = await fetch(`${LUOXU_URL}/groups`) const res = await fetch(`${LUOXU_URL}/groups`);
groups = (await res.json()).groups groups = (await res.json()).groups;
need_update_title = true need_update_title = true;
if (!group) { if (!group) {
group = '' group = "";
} }
break break;
} catch (e) { } catch (e) {
console.error('failed to fetch group info, will retry', e) console.error("failed to fetch group info, will retry", e);
await sleep(1000) await sleep(1000);
} }
} }
}) });
$: { $: {
// only update title on hash change (doing a search) // only update title on hash change (doing a search)
if (need_update_title && groups) { if (need_update_title && groups) {
let group_name let group_name: any;
for (const g of groups) { for (const g of groups) {
if(g.group_id == group) { if (g.group_id === group) {
group_name = g.name group_name = g.name;
} }
} }
if (query && group_name) { if (query && group_name) {
document.title = `搜索:${query} 于 ${group_name} - 落絮` document.title = `搜索:${query} 于 ${group_name} - 落絮`;
} else if (query) { } else if (query) {
document.title = `搜索:${query} - 落絮` document.title = `搜索:${query} - 落絮`;
} else if (group_name) { } else if (group_name) {
document.title = `搜索 ${group_name} - 落絮` document.title = `搜索 ${group_name} - 落絮`;
} else { } else {
document.title = '落絮' document.title = "落絮";
} }
need_update_title = false need_update_title = false;
} }
} }
function do_hash_search() { function do_hash_search() {
const info = parse_hash() const info = parse_hash();
if (info) { if (info) {
query = '' query = "";
group = '' group = "";
result = null result = null;
if(info.has('g')) { if (info.has("g")) {
group = info.get('g')|0 group = (parseFloat(info.get("g")) | 0).toString();
} }
if(info.has('q')) { if (info.has("q")) {
query = info.get('q') query = info.get("q");
} }
if(info.has('sender')) { if (info.has("sender")) {
sender = info.get('sender') sender = info.get("sender");
selected_init = sender selected_init = sender;
} }
if ((group || islocal) && query) { if ((group || islocal) && query) {
result = null result = null;
do_search() do_search();
} }
} }
} }
async function do_search(more) { async function do_search(more?: any) {
if (!group && !islocal) { if (!group && !islocal) {
error = '请选择要搜索的群组' error = "请选择要搜索的群组";
return return;
} }
if (!query && !islocal) { if (!query && !islocal) {
error = '请输入搜索关键字' error = "请输入搜索关键字";
return return;
} }
error = '' error = "";
our_hash_change = true our_hash_change = true;
console.log(`searching ${query} for group ${group}, older than ${more}, from ${sender}`) console.log(
const q = new URLSearchParams() `searching ${query} for group ${group}, older than ${more}, from ${sender}`
);
const q = new URLSearchParams();
if (group) { if (group) {
q.append('g', group) q.append("g", group);
} }
if (query) { if (query) {
q.append('q', query) q.append("q", query);
} }
if (sender) { if (sender) {
q.append('sender', sender) q.append("sender", sender);
} }
let url let url: RequestInfo | URL;
const qstr = q.toString() const qstr = q.toString();
if (!more) { if (!more) {
location.hash = `#${qstr}` location.hash = `#${qstr}`;
need_update_title = true need_update_title = true;
if (result) { if (result) {
result.messages = [] result.messages = [];
} }
url = `${LUOXU_URL}/search?${qstr}` url = `${LUOXU_URL}/search?${qstr}`;
} else { } else {
url = `${LUOXU_URL}/search?${q}&end=${more}` url = `${LUOXU_URL}/search?${q}&end=${more}`;
} }
now = new Date() now = new Date();
loading = true loading = true;
try { try {
const res = await fetch(url) const res = await fetch(url);
const r = await res.json() const r = await res.json();
loading = false loading = false;
if (more) { if (more) {
return r return r;
} else { } else {
result = r result = r;
} }
} catch (e) { } catch (e) {
error = e error = e;
loading = false loading = false;
} }
our_hash_change = false; our_hash_change = false;
} }
async function on_group_change() { async function on_group_change() {
error = '' error = "";
if (query) { if (query) {
await do_search() await do_search();
} }
} }
async function do_search_more() { async function do_search_more() {
const more = result.messages[result.messages.length-1].t const more = result.messages[result.messages.length - 1].t;
const old_msgs = result.messages const old_msgs = result.messages;
const new_result = await do_search(more) const new_result = await do_search(more);
result.messages = [...old_msgs, ...new_result.messages] result.messages = [...old_msgs, ...new_result.messages];
result.has_more = new_result.has_more result.has_more = new_result.has_more;
} }
</script> </script>
<svelte:window on:hashchange={() => {if(!our_hash_change) do_hash_search()}}/> <svelte:window
on:hashchange={() => {
if (!our_hash_change) do_hash_search();
}}
/>
<main> <main>
<div id="searchbox"> <div id="searchbox">
{#if groups.length == 0} {#if groups.length === 0}
<select> <select>
<option selected>正在加载群组信息...</option> <option selected>正在加载群组信息...</option>
</select> </select>
@ -177,21 +183,30 @@
{/each} {/each}
</select> </select>
{/if} {/if}
<input type="search" bind:value={query} <input
on:input={() => error = ''} type="search"
on:keydown={e => {if(e.key == 'Enter'){do_search()}}} bind:value={query}
on:input={() => (error = "")}
on:keydown={(e) => {
if (e.key === "Enter") {
do_search();
}
}}
/> />
<Name group={group} bind:selected={sender} selected_init={selected_init}/> <Name {group} bind:selected={sender} {selected_init} />
<button on:click={() => do_search()}>搜索</button> <button on:click={() => do_search()}>搜索</button>
</div> </div>
{#if result} {#if result}
{#each result.messages as message} {#each result.messages as message}
<Message msg={message} groupinfo={result.groupinfo} now={now} /> <Message msg={message} groupinfo={result.groupinfo} {now} />
{/each} {/each}
{:else if !loading && !error} {:else if !loading && !error}
<div> <div>
<p>搜索消息时,搜索字符串不区分简繁(会使用 OpenCC 自动转换),也不进行分词(请手动将可能不连在一起的词语以空格分开)。</p> <p>
搜索消息时,搜索字符串不区分简繁(会使用 OpenCC
自动转换),也不进行分词(请手动将可能不连在一起的词语以空格分开)。
</p>
<p>搜索字符串支持以下功能:</p> <p>搜索字符串支持以下功能:</p>
<ul> <ul>
<li>以空格分开的多个搜索词是「与」的关系</li> <li>以空格分开的多个搜索词是「与」的关系</li>
@ -200,7 +215,10 @@
<li>使用小括号来分组</li> <li>使用小括号来分组</li>
</ul> </ul>
<p>人名补全支持上下方向键和 Alt+N/P 进行选择。</p> <p>人名补全支持上下方向键和 Alt+N/P 进行选择。</p>
<p>搜索结果右下角的时间,悬停可查看绝对时间、最后编辑时间(如编辑过),点击可跳转到 Telegram 中展示该消息。</p> <p>
搜索结果右下角的时间,悬停可查看绝对时间、最后编辑时间(如编辑过),点击可跳转到
Telegram 中展示该消息。
</p>
</div> </div>
{/if} {/if}
@ -209,13 +227,15 @@
{:else} {:else}
{#if error} {#if error}
<p class="error">{error}</p> <p class="error">{error}</p>
{:else if result && result.messages.length == 0} {:else if result && result.messages.length === 0}
<div class="info"><p>没有匹配的消息。</p></div> <div class="info"><p>没有匹配的消息。</p></div>
{:else if result && !result.has_more} {:else if result && !result.has_more}
<div class="info"><p>到底了。</p></div> <div class="info"><p>到底了。</p></div>
{/if} {/if}
{#if result && result.has_more} {#if result && result.has_more}
<div class="info"><button on:click={do_search_more}>加载更多</button></div> <div class="info">
<button on:click={do_search_more}>加载更多</button>
</div>
{/if} {/if}
{/if} {/if}
</main> </main>
@ -232,7 +252,7 @@
#searchbox { #searchbox {
display: flex; display: flex;
} }
#searchbox input[type=search] { #searchbox input[type="search"] {
flex-grow: 1; flex-grow: 1;
} }
@media (max-width: 700px) { @media (max-width: 700px) {
@ -259,12 +279,16 @@
border-radius: 2em; border-radius: 2em;
} }
:global(input), :global(button), :global(select) { :global(input),
:global(button),
:global(select) {
border-radius: 0; border-radius: 0;
border: 1px solid var(--color-inactive); border: 1px solid var(--color-inactive);
height: 2.3em; height: 2.3em;
} }
:global(input:focus), :global(button:focus), :global(select:focus) { :global(input:focus),
:global(button:focus),
:global(select:focus) {
border-color: var(--color-active); border-color: var(--color-active);
outline: 1px solid var(--color-active); outline: 1px solid var(--color-active);
} }

View File

@ -1,58 +1,66 @@
<script> <script lang="ts">
import { onMount, getContext } from 'svelte' import { onMount, getContext } from "svelte";
export let msg export let msg: any;
export let groupinfo export let groupinfo: any;
export let now export let now: any;
const formatter = new Intl.DateTimeFormat(undefined, { const formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "full", timeStyle: "full",
dateStyle: "full", dateStyle: "full",
hour12: false, hour12: false,
}) });
const format_dt = formatter.format;
let dt = new Date(msg.t * 1000);
let edited = msg.edited ? new Date(msg.edited * 1000) : null;
let title =
format_dt(dt) + (edited ? `\n最后编辑于${format_dt(edited)}` : "");
let relative_dt = format_relative_time(dt, now);
let iso_date = dt.toISOString();
let msgurl = groupinfo[msg.group_id][0]
? `tg://resolve?domain=${groupinfo[msg.group_id][0]}&post=${msg.id}`
: `tg://privatepost?channel=${msg.group_id}&post=${msg.id}`;
let dt = new Date(msg.t * 1000) function format_relative_time(d1: Date, d2: Date) {
let edited = msg.edited ? new Date(msg.edited * 1000) : null
let title = format_dt(dt) + (edited ? `\n最后编辑于${format_dt(edited)}` : '')
let relative_dt = format_relative_time(dt, now)
let iso_date = dt.toISOString()
let msgurl = groupinfo[msg.group_id][0] ? `tg://resolve?domain=${groupinfo[msg.group_id][0]}&post=${msg.id}` : `tg://privatepost?channel=${msg.group_id}&post=${msg.id}`
function format_relative_time(d1, d2) {
// in miliseconds // in miliseconds
const units = { const units = {
year: 24 * 60 * 60 * 1000 * 365, year: 24 * 60 * 60 * 1000 * 365,
month : 24 * 60 * 60 * 1000 * 365 / 12, month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000, day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000, hour: 60 * 60 * 1000,
minute: 60 * 1000, minute: 60 * 1000,
second: 1000, second: 1000,
} };
const rtf = new Intl.RelativeTimeFormat() const rtf = new Intl.RelativeTimeFormat();
//@ts-ignore
const elapsed = d1 - d2; //https://stackoverflow.com/a/4944782/13040423
const elapsed = d1 - d2 for (const [u, period] of Object.entries(units)) {
if (Math.abs(elapsed) > period || u === "second") {
for(const u in units) { //@ts-ignore
if(Math.abs(elapsed) > units[u] || u == 'second') { return rtf.format(Math.round(elapsed / period), u);
return rtf.format(Math.round(elapsed/units[u]), u)
} }
} }
} }
function format_dt(t) {
return formatter.format(t)
}
</script> </script>
<div class="message"> <div class="message">
<img class="avatar" src="{getContext('LUOXU_URL')}/avatar/{msg.from_id}.jpg" height="64" width="64" alt="{msg.from_name} 的头像"/> <img
class="avatar"
src="{getContext('LUOXU_URL')}/avatar/{msg.from_id}.jpg"
height="64"
width="64"
alt="{msg.from_name} 的头像"
/>
<div class="content"> <div class="content">
<div class="name">{msg.from_name || ' '}</div> <div class="name">{msg.from_name || " "}</div>
<div class="text">{@html msg.html}</div> <div class="text">{@html msg.html}</div>
<div class="time">{groupinfo[msg.group_id][1]} <a href={msgurl}><time datetime={iso_date} title={title}>{relative_dt}</time></a></div> <div class="time">
{groupinfo[msg.group_id][1]}
<a href={msgurl}><time datetime={iso_date} {title}>{relative_dt}</time></a
>
</div>
</div> </div>
</div> </div>
@ -89,7 +97,8 @@
font-size: 0.75em; font-size: 0.75em;
float: right; float: right;
} }
.time, .time > a { .time,
.time > a {
color: gray; color: gray;
} }
</style> </style>

View File

@ -1,140 +1,159 @@
<script> <script lang="ts">
import { onMount, getContext } from 'svelte' import { onMount, getContext } from "svelte";
export let group export let group: any;
export let selected export let selected: any;
export let selected_init export let selected_init: any;
let selected_name = '' let selected_name = "";
let selected_idx let selected_idx: number;
let to let to: string | number | NodeJS.Timeout;
let names = [] let names = [];
let url = getContext('LUOXU_URL') let url = getContext("LUOXU_URL");
let input let input: HTMLInputElement;
let ul let ul: HTMLUListElement;
let should_hide = false let should_hide = false;
let abort = new AbortController() let abort = new AbortController();
onMount(() => { onMount(() => {
const rect = input.getBoundingClientRect() const rect = input.getBoundingClientRect();
ul.style.top = `${rect.height - 1}px` ul.style.top = `${rect.height - 1}px`;
ul.style.width = `${rect.width - 2}px` ul.style.width = `${rect.width - 2}px`;
}) });
function update_list_width() { function update_list_width() {
const rect = input.getBoundingClientRect() const rect = input.getBoundingClientRect();
ul.style.width = `${rect.width - 2}px` ul.style.width = `${rect.width - 2}px`;
} }
function may_complete() { function may_complete() {
if (to) { if (to) {
clearTimeout(to) clearTimeout(to);
} }
to = setTimeout(function () { to = setTimeout(function () {
complete_it() complete_it();
}, 300) }, 300);
} }
async function complete_it() { async function complete_it() {
if (!input.value) { if (!input.value) {
return return;
} }
selected_idx = null selected_idx = null;
abort.abort() abort.abort();
abort = new AbortController() abort = new AbortController();
try { try {
const res = await fetch(`${url}/names?g=${group}&q=${input.value}`, const res = await fetch(`${url}/names?g=${group}&q=${input.value}`, {
{signal: abort.signal}) signal: abort.signal,
const j = await res.json() });
if(!abort.aborted) { const j = await res.json();
if (!abort.signal.aborted) {
// only update if we're current // only update if we're current
names = j.names names = j.names;
} }
} catch (e) { } catch (e) {
if(e instanceof DOMException && e.name == 'AbortError'){ if (e instanceof DOMException && e.name === "AbortError") {
} else { } else {
console.error(e) console.error(e);
} }
} }
} }
function select_by_click(e) { function select_by_click(e: any) {
let el = e.target let el = e.target;
if(el.tagName == 'IMG') { if (el.tagName === "IMG") {
el = el.parentNode el = el.parentNode;
} }
if(el.tagName != 'LI') { if (el.tagName != "LI") {
return return;
} }
selected_idx = el.dataset.idx|0 selected_idx = el.dataset.idx | 0;
select_confirmed() select_confirmed();
input.focus() input.focus();
should_hide = true should_hide = true;
} }
function select_confirmed() { function select_confirmed() {
selected = names[selected_idx][0] selected = names[selected_idx][0];
selected_name = names[selected_idx][1] selected_name = names[selected_idx][1];
input.value = selected_name input.value = selected_name;
selected_init = null selected_init = null;
} }
function update_value() { function update_value() {
if (!selected || selected === selected_init) { if (!selected || selected === selected_init) {
return return;
} }
if (input.value) { if (input.value) {
input.value = selected_name input.value = selected_name;
} else { } else {
selected = selected_init selected = selected_init;
selected_name = '' selected_name = "";
} }
} }
function select_by_key(e) { function select_by_key(e: KeyboardEvent) {
if(e.key == 'ArrowDown' || (e.key == 'n' && e.altKey)) { if (e.key === "ArrowDown" || (e.key === "n" && e.altKey)) {
select_next(1) select_next(1);
e.preventDefault() e.preventDefault();
}else if(e.key == 'ArrowUp' || (e.key == 'p' && e.altKey)) { } else if (e.key === "ArrowUp" || (e.key === "p" && e.altKey)) {
select_next(-1) select_next(-1);
e.preventDefault() e.preventDefault();
}else if(e.key == 'Enter') { } else if (e.key === "Enter") {
select_confirmed() select_confirmed();
e.preventDefault() e.preventDefault();
} }
} }
function select_next(dir) { function select_next(dir: number) {
if(typeof selected_idx === 'number') { if (typeof selected_idx === "number") {
if (dir > 0) { if (dir > 0) {
selected_idx = (selected_idx + 1) % names.length selected_idx = (selected_idx + 1) % names.length;
} else { } else {
selected_idx = (selected_idx - 1) % names.length selected_idx = (selected_idx - 1) % names.length;
} }
} else { } else {
if (dir > 0) { if (dir > 0) {
selected_idx = 0 selected_idx = 0;
} else { } else {
selected_idx = names.length - 1 selected_idx = names.length - 1;
} }
} }
} }
</script> </script>
<div> <div>
<input bind:this={input} type="text" <input
on:input={() => {should_hide=false;may_complete()}} bind:this={input}
on:focus={() => should_hide=false} type="text"
on:blur={() => {should_hide=true;update_value()}} on:input={() => {
should_hide = false;
may_complete();
}}
on:focus={() => (should_hide = false)}
on:blur={() => {
should_hide = true;
update_value();
}}
on:keydown={select_by_key} on:keydown={select_by_key}
/> />
<img class="selected-avatar" alt="" src="{url}/avatar/{selected?selected:'nobody'}.jpg"/> <img
<ul bind:this={ul} on:click={select_by_click} on:mousedown|preventDefault={()=>{}} class:hidden={names.length === 0 || should_hide}> class="selected-avatar"
alt=""
src="{url}/avatar/{selected ? selected : 'nobody'}.jpg"
/>
<ul
bind:this={ul}
on:click={select_by_click}
on:mousedown|preventDefault={() => {}}
class:hidden={names.length === 0 || should_hide}
>
{#each names as name, i (name)} {#each names as name, i (name)}
<li data-idx={i} class:selected={i===selected_idx} title={name[1]}><img src="{url}/avatar/{name[0]}.jpg" alt="avatar"/>{name[1]}</li> <li data-idx={i} class:selected={i === selected_idx} title={name[1]}>
<img src="{url}/avatar/{name[0]}.jpg" alt="avatar" />{name[1]}
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
@ -171,18 +190,21 @@
display: inline-block; display: inline-block;
overflow: hidden; overflow: hidden;
} }
ul:not(:hover) > li.selected, li:hover { ul:not(:hover) > li.selected,
li:hover {
background-color: #d9f5ff; background-color: #d9f5ff;
} }
input { input {
padding-left: 2.5em; padding-left: 2.5em;
width: 100%; width: 100%;
} }
input, ul { input,
ul {
border-radius: 0; border-radius: 0;
border: 1px solid var(--color-inactive); border: 1px solid var(--color-inactive);
} }
input:focus, input:focus ~ ul { input:focus,
input:focus ~ ul {
border-color: var(--color-active); border-color: var(--color-active);
box-shadow: 0 0 4px var(--color-active); box-shadow: 0 0 4px var(--color-active);
outline: 1px solid var(--color-active); outline: 1px solid var(--color-active);

1
src/global.d.ts vendored Normal file
View File

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

View File

@ -1,7 +0,0 @@
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;

7
src/main.ts Normal file
View File

@ -0,0 +1,7 @@
import App from "./App.svelte";
const app = new App({
target: document.body,
});
export default app;

View File

@ -1,8 +0,0 @@
'use strict'
export function sleep(ms) {
const p = new Promise((resolve, reject) => {
setTimeout(resolve, ms)
})
return p
}

6
src/util.ts Normal file
View File

@ -0,0 +1,6 @@
export function sleep(ms: number) {
const p = new Promise((resolve, reject) => {
setTimeout(resolve, ms);
});
return p;
}

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}