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">
import Field from "@/components/Field.vue";
import {type Field as FieldSpec} from "@/urlmaker/specs";
import {validateOr, validatePreset, validateUrl} from "@/urlmaker/validators.ts";
import TextField from "@/components/TextField.vue";
import Btn from "@/components/Btn.vue";
import {onMounted, onUnmounted, ref, watch} from "vue";
import Modal from "@/components/Modal.vue";
const field: FieldSpec = {
name: '',
input_type: 'url',
label: 'URL of feed or preset',
default: '',
required: true,
validate: validateOr(validateUrl, validatePreset),
}
import {validatePreset, validateUrl} from "@/urlmaker/validators.ts";
const visible = defineModel('visible', {
type: Boolean,
@ -33,11 +23,10 @@ watch(visible, () => {
const valid = ref(false);
watch(url, (value) => {
valid.value = field.validate(value).ok;
valid.value = validateUrl(value) || validatePreset(value);
});
const accept = () => {
valid.value = field.validate(url.value).ok;
if (valid.value) {
emit('update:modelValue', url.value);
emit('update:visible', false);
@ -59,7 +48,13 @@ onUnmounted(() => {
<template>
<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>
</Modal>
</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;
existingLink.value = "";
try {
if(validateUrl(value).ok) store.updateSpecs(await decodeUrl(value));
else if (validatePreset(value).ok) store.updateSpecs(await decodePreset(value));
if(validateUrl(value)) store.updateSpecs(await decodeUrl(value));
else if (validatePreset(value)) store.updateSpecs(await decodePreset(value));
} catch (e) {
console.log(e);
alert(`Decoding error: ${e}`);
@ -60,7 +60,6 @@ function screenshot() {
<template>
<div class="wrapper">
<SpecsForm class="specs-form"></SpecsForm>
<!-- <Btn :active="store.formValid" @click="generateLink">Generate link</Btn>-->
<Btn :active="store.formValid" @click="screenshot">Screenshot</Btn>
<Btn @click="editModalVisible = true">Edit existing task / import preset</Btn>
<Btn @click="store.reset">Reset Form</Btn>

View File

@ -1,5 +1,5 @@
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 {debounce} from "es-toolkit";
@ -14,7 +14,7 @@ export const useWizardStore = defineStore('wizard', () => {
const formValid = computed(() => {
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));
}, 100);
function updateSpec(fieldName: FieldNames, newValue: string) {
function updateSpec(fieldName: keyof Specs, newValue: string) {
specs[fieldName] = newValue;
updateLocalStorage();
}

View File

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

View File

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