frontend fields refactoring

This commit is contained in:
Egor Aristov 2025-05-06 16:49:16 +03:00
parent 3e53958a35
commit ae2cdb4f14
Signed by: egor3f
GPG Key ID: 40482A264AAEC85F
6 changed files with 111 additions and 81 deletions

View File

@ -1,20 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import Field from "@/components/Field.vue"; import TextField from "@/components/TextField.vue";
import {type Field as FieldSpec} from "@/urlmaker/specs";
import {validateOr, validatePreset, validateUrl} from "@/urlmaker/validators.ts";
import Btn from "@/components/Btn.vue"; import Btn from "@/components/Btn.vue";
import {onMounted, onUnmounted, ref, watch} from "vue"; import {onMounted, onUnmounted, ref, watch} from "vue";
import Modal from "@/components/Modal.vue"; import Modal from "@/components/Modal.vue";
import {validatePreset, validateUrl} from "@/urlmaker/validators.ts";
const field: FieldSpec = {
name: '',
input_type: 'url',
label: 'URL of feed or preset',
default: '',
required: true,
validate: validateOr(validateUrl, validatePreset),
}
const visible = defineModel('visible', { const visible = defineModel('visible', {
type: Boolean, type: Boolean,
@ -33,11 +23,10 @@ watch(visible, () => {
const valid = ref(false); const valid = ref(false);
watch(url, (value) => { watch(url, (value) => {
valid.value = field.validate(value).ok; valid.value = validateUrl(value) || validatePreset(value);
}); });
const accept = () => { const accept = () => {
valid.value = field.validate(url.value).ok;
if (valid.value) { if (valid.value) {
emit('update:modelValue', url.value); emit('update:modelValue', url.value);
emit('update:visible', false); emit('update:visible', false);
@ -59,7 +48,13 @@ onUnmounted(() => {
<template> <template>
<Modal v-model="visible"> <Modal v-model="visible">
<Field :field="field" v-model="url" :focused="true"/> <TextField
name="url"
input_type="url"
label="URL of feed or preset"
v-model="url"
:focused="true"
/>
<Btn :active="valid" @click="accept">Edit</Btn> <Btn :active="valid" @click="accept">Edit</Btn>
</Modal> </Modal>
</template> </template>

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import {getCurrentInstance, onMounted, useTemplateRef} from "vue";
const {name, label, input_type, focused} = defineProps<{
name: string
label: string,
input_type: 'text' | 'url',
focused?: boolean,
}>();
const id = 'field' + getCurrentInstance()?.uid;
const model = defineModel();
const inputRef = useTemplateRef('field');
onMounted(() => {
if(focused) inputRef.value?.focus();
})
</script>
<template>
<div class="field">
<div class="label"><label :for="id">{{ label }}</label></div>
<div class="input">
<input :type="input_type" :name="name" :id="id" v-model="model" ref="field"/>
</div>
</div>
</template>
<style scoped lang="scss">
div.field {
margin: 0 0 8px 0;
}
div.label {
font-size: 0.9em;
}
div.input {
margin: 2px 0 0 0;
box-sizing: border-box;
input {
box-sizing: border-box;
width: 100%;
padding: 2px;
}
}
</style>

View File

@ -19,8 +19,8 @@ watch(existingLink, async (value) => {
if(!value) return; if(!value) return;
existingLink.value = ""; existingLink.value = "";
try { try {
if(validateUrl(value).ok) store.updateSpecs(await decodeUrl(value)); if(validateUrl(value)) store.updateSpecs(await decodeUrl(value));
else if (validatePreset(value).ok) store.updateSpecs(await decodePreset(value)); else if (validatePreset(value)) store.updateSpecs(await decodePreset(value));
} catch (e) { } catch (e) {
console.log(e); console.log(e);
alert(`Decoding error: ${e}`); alert(`Decoding error: ${e}`);
@ -60,7 +60,6 @@ function screenshot() {
<template> <template>
<div class="wrapper"> <div class="wrapper">
<SpecsForm class="specs-form"></SpecsForm> <SpecsForm class="specs-form"></SpecsForm>
<!-- <Btn :active="store.formValid" @click="generateLink">Generate link</Btn>-->
<Btn :active="store.formValid" @click="screenshot">Screenshot</Btn> <Btn :active="store.formValid" @click="screenshot">Screenshot</Btn>
<Btn @click="editModalVisible = true">Edit existing task / import preset</Btn> <Btn @click="editModalVisible = true">Edit existing task / import preset</Btn>
<Btn @click="store.reset">Reset Form</Btn> <Btn @click="store.reset">Reset Form</Btn>

View File

@ -1,5 +1,5 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {emptySpecs, type Field, type FieldNames, fields, type Specs} from "@/urlmaker/specs.ts"; import {emptySpecs, type SpecField, fields, type Specs} from "@/urlmaker/specs.ts";
import {computed, reactive} from "vue"; import {computed, reactive} from "vue";
import {debounce} from "es-toolkit"; import {debounce} from "es-toolkit";
@ -14,7 +14,7 @@ export const useWizardStore = defineStore('wizard', () => {
const formValid = computed(() => { const formValid = computed(() => {
return fields.every(field => ( return fields.every(field => (
specs[field.name].length === 0 && !(field as Field).required || field.validate(specs[field.name]).ok !specs[field.name] && !(field as SpecField).required || field.validate(specs[field.name]!)
)); ));
}); });
@ -22,7 +22,7 @@ export const useWizardStore = defineStore('wizard', () => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(specs)); localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(specs));
}, 100); }, 100);
function updateSpec(fieldName: FieldNames, newValue: string) { function updateSpec(fieldName: keyof Specs, newValue: string) {
specs[fieldName] = newValue; specs[fieldName] = newValue;
updateLocalStorage(); updateLocalStorage();
} }

View File

@ -4,20 +4,34 @@ import {
validateUrl, validateUrl,
type validator type validator
} from "@/urlmaker/validators.ts"; } from "@/urlmaker/validators.ts";
import {rssalchemy} from "@/urlmaker/proto/specs.ts";
export interface Field { export type SpecKey = ReturnType<rssalchemy.Specs['toObject']>;
name: string export type SpecValue = string | number;
input_type: string export type Specs = {[k in keyof SpecKey]: SpecValue};
label: string
default: string export enum InputType {
validate: validator Url = 'url',
required?: boolean Text = 'text',
Radio = 'radio'
} }
export const fields = [ export interface SpecField {
name: keyof Specs
input_type: InputType
enum_values?: {[k: number]: string}
label: string
default: SpecValue
validate: validator
required?: boolean
group?: string
show_if?: (specs: Specs) => boolean
}
export const fields: SpecField[] = [
{ {
name: 'url', name: 'url',
input_type: 'url', input_type: InputType.Url,
label: 'URL of page for converting', label: 'URL of page for converting',
default: '', default: '',
validate: validateUrl, validate: validateUrl,
@ -25,72 +39,71 @@ export const fields = [
}, },
{ {
name: 'selector_post', name: 'selector_post',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for post', label: 'CSS Selector for post',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_title', name: 'selector_title',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for title', label: 'CSS Selector for title',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_link', name: 'selector_link',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for link', label: 'CSS Selector for link',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_description', name: 'selector_description',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for description', label: 'CSS Selector for description',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_author', name: 'selector_author',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for author', label: 'CSS Selector for author',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_created', name: 'selector_created',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for created date', label: 'CSS Selector for created date',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
group: 'created',
}, },
{ {
name: 'selector_content', name: 'selector_content',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for content', label: 'CSS Selector for content',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'selector_enclosure', name: 'selector_enclosure',
input_type: 'text', input_type: InputType.Text,
label: 'CSS Selector for enclosure (e.g. image url)', label: 'CSS Selector for enclosure (e.g. image url)',
default: '', default: '',
validate: validateSelector, validate: validateSelector,
}, },
{ {
name: 'cache_lifetime', name: 'cache_lifetime',
input_type: 'text', input_type: InputType.Text,
label: 'Cache lifetime (format examples: 10s, 1m, 2h)', label: 'Cache lifetime (format examples: 10s, 1m, 2h)',
default: '10m', default: '10m',
validate: validateDuration, validate: validateDuration,
}, },
] as const satisfies Field[]; ];
export type FieldNames = (typeof fields)[number]['name'];
export type Specs = {[k in FieldNames]: string};
export const emptySpecs = fields.reduce((o, f) => { export const emptySpecs = fields.reduce((o, f) => {
o[f.name] = f.default; o[f.name] = f.default;

View File

@ -1,54 +1,31 @@
import {presetPrefix} from "@/urlmaker/index.ts"; import {presetPrefix} from "@/urlmaker/index.ts";
import type {SpecValue} from "@/urlmaker/specs.ts";
type validResult = { ok: boolean, error?: string }; export type validator = (v: SpecValue) => boolean;
export type validator = (v: string) => validResult
export function validateUrl(s: string): validResult { export function validateUrl(s: SpecValue): boolean {
let url; let url;
try { try {
url = new URL(s); url = new URL(s as string);
return { return url.protocol === "http:" || url.protocol === "https:"
ok: url.protocol === "http:" || url.protocol === "https:",
error: 'Invalid URL protocol',
};
} catch { } catch {
return {ok: false, error: 'Invalid URL'}; return false;
} }
} }
export function validatePreset(s: string): validResult { export function validatePreset(s: SpecValue): boolean {
if(!s.startsWith(presetPrefix)) { return (s as string).startsWith(presetPrefix);
return {
ok: false,
error: 'Not a preset'
}
}
return {ok: true}
} }
export function validateOr(...validators: validator[]): validator { export function validateSelector(s: SpecValue): boolean {
return function(s: string): validResult {
return validators.reduce<validResult>((res, v) => {
let r = v(s);
if(r.ok) res.ok = true;
else res.error += r.error + '; ';
return res;
}, {ok: false, error: ''});
}
}
export function validateSelector(s: string): validResult {
try { try {
document.createDocumentFragment().querySelector(s); document.createDocumentFragment().querySelector(s as string);
return {ok: true} return true;
} catch { } catch {
return {ok: false, error: 'Invalid selector'}; return false;
} }
} }
export function validateDuration(s: string): validResult { export function validateDuration(s: SpecValue): boolean {
return { return /^\d+[smh]$/.test(s as string);
ok: /^\d+[smh]$/.test(s),
error: 'Duration must be number and unit (s/m/h), example: 5s = 5 seconds'
}
} }