wizard-vue

This commit is contained in:
Egor Aristov 2025-01-30 05:56:07 +03:00
parent f6f3ae3be5
commit aca243cef7
25 changed files with 6074 additions and 1 deletions

View File

@ -178,7 +178,7 @@
APPENDIX: How to apply the Apache License to your work. APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]" boilerplate notice, with the specs enclosed by brackets "[]"
replaced with your own identifying information. (Don't include replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a comment syntax for the file format. We also recommend that a

View File

@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
frontend/wizard-vue/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

30
frontend/wizard-vue/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

1
frontend/wizard-vue/env.d.ts vendored Normal file
View File

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

View File

@ -0,0 +1,24 @@
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

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>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5542
frontend/wizard-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "wizard-vue",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@kalimahapps/vue-icons": "^1.7.1",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"jiti": "^2.4.2",
"npm-run-all2": "^7.0.2",
"prettier": "^3.4.2",
"sass-embedded": "^1.83.4",
"typescript": "~5.7.3",
"vite": "^6.0.11",
"vite-plugin-vue-devtools": "^7.7.0",
"vue-tsc": "^2.2.0"
}
}

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped>
</style>

View File

@ -0,0 +1,3 @@
body {
font-family: "system-ui", "Segoe UI", Helvetica, Arial, sans-serif;
}

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
const {active} = defineProps({
active: Boolean
});
</script>
<template>
<div class="wrapper" :class="{active: active}">
<slot></slot>
</div>
</template>
<style scoped lang="scss">
div.wrapper {
display: inline-block;
border-radius: 4px;
margin: 4px 8px 0 0;
padding: 4px 6px;
user-select: none;
background-color: #ffffff;
border: 1px solid #868686;
color: #505050;
&.active {
background-color: #f2f2f2;
box-shadow: #acacac 1px 1px;
border: 1px solid #464646;
color: black;
cursor: pointer;
&:hover {
background-color: #d5d5d5;
}
&:active {
background-color: #c0c0c0;
}
}
}
</style>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { MdContentCopy } from '@kalimahapps/vue-icons';
import {ref} from "vue";
const {contents} = defineProps({
contents: String,
});
const copiedTooltip = ref(false);
async function copy() {
if(contents) {
await navigator.clipboard.writeText(contents);
copiedTooltip.value = true;
setTimeout(() => {
copiedTooltip.value = false;
}, 1000);
}
}
</script>
<template>
<div class="copyable">
<span class="contents">{{ contents }}</span>
<span class="copy" v-if="copiedTooltip">Copied!</span>
<span class="copy" @click="copy" v-else><MdContentCopy class="icon"/></span>
</div>
</template>
<style scoped lang="scss">
div.copyable {
display: flex;
flex-flow: row nowrap;
align-items: center;
border: 1px solid #464646;
border-radius: 2px;
margin: 4px 4px 0 0;
user-select: all;
span.contents {
flex: 1;
padding: 4px;
}
span.copy {
flex: 0;
cursor: pointer;
user-select: none;
padding: 4px;
.icon {
display: block;
font-size: 18px;
}
}
}
</style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import type {Field} from "@/urlmaker/specs.ts";
import {getCurrentInstance} from "vue";
defineProps<{
field: Field
}>();
const id = 'field' + getCurrentInstance()?.uid;
const model = defineModel();
</script>
<template>
<div class="field">
<div class="label"><label :for="id">{{ field.label }}</label></div>
<div class="input">
<input :type="field.input_type" :name="field.name" :id="id" v-model="model"/>
</div>
</div>
</template>
<style scoped lang="scss">
div.field {
margin: 0 0 8px 0;
}
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import {fields, type Specs} from '@/urlmaker/specs.ts';
import Field from "@/components/Field.vue";
const model = defineModel<Specs>({required: true});
</script>
<template>
<div>
<Field v-for="field in fields" :field="field" v-model="model[field.name]"></Field>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,14 @@
import './assets/base.scss'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import SpecsForm from "@/components/SpecsForm.vue";
import {reactive, ref, watch} from "vue";
import {type Field, fields, type Specs} from "@/urlmaker/specs.ts";
import Btn from "@/components/Btn.vue";
import Copyable from "@/components/Copyable.vue";
const emptySpecs = fields.reduce((o, f) => {
o[f.name] = f.default;
return o
}, {} as Specs);
const specs = reactive(emptySpecs);
const formValid = ref(false);
watch(specs, (value, oldValue) => {
formValid.value = fields.every(field => (
specs[field.name].length === 0 && !(field as Field).required || field.validate(specs[field.name]).ok
));
});
const link = ref("https://kek.com");
</script>
<template>
<SpecsForm v-model="specs" class="specs-form"></SpecsForm>
<Btn :active="formValid">Generate link</Btn>
<Btn :active="formValid">Screenshot</Btn>
<Copyable v-if="link" :contents="link" class="link-view"></Copyable>
</template>
<style scoped lang="scss">
.specs-form {
margin-bottom: 15px;
}
.link-view {
margin-top: 15px !important;
}
</style>

View File

@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import WizardPage from "@/pages/WizardPage.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'wizard',
component: WizardPage,
},
],
})
export default router

View File

@ -0,0 +1,93 @@
import {
validateDuration,
validateSelector,
validateUrl,
type validator
} from "@/urlmaker/validators.ts";
export interface Field {
name: string
input_type: string
label: string
default: string
validate: validator
required?: boolean
}
export const fields = [
{
name: 'url',
input_type: 'url',
label: 'URL of page for converting',
default: '',
validate: validateUrl,
required: true,
},
{
name: 'selector_post',
input_type: 'text',
label: 'CSS Selector for post',
default: '',
validate: validateSelector,
},
{
name: 'selector_title',
input_type: 'text',
label: 'CSS Selector for title',
default: '',
validate: validateSelector,
},
{
name: 'selector_link',
input_type: 'text',
label: 'CSS Selector for link',
default: '',
validate: validateSelector,
},
{
name: 'selector_description',
input_type: 'text',
label: 'CSS Selector for description',
default: '',
validate: validateSelector,
},
{
name: 'selector_author',
input_type: 'text',
label: 'CSS Selector for author',
default: '',
validate: validateSelector,
},
{
name: 'selector_created',
input_type: 'text',
label: 'CSS Selector for created date',
default: '',
validate: validateSelector,
},
{
name: 'selector_content',
input_type: 'text',
label: 'CSS Selector for content',
default: '',
validate: validateSelector,
},
{
name: 'selector_enclosure',
input_type: 'text',
label: 'CSS Selector for enclosure (e.g. image url)',
default: '',
validate: validateSelector,
},
{
name: 'cache_lifetime',
input_type: 'text',
label: 'Cache lifetime (format examples: 10s, 1m, 2h)',
default: '1m',
validate: validateDuration,
},
] as const satisfies Field[];
export type FieldNames = (typeof fields)[number]['name'];
export type Specs = {[k in FieldNames]: string}

View File

@ -0,0 +1,31 @@
type validResult = { ok: boolean, error?: string };
export type validator = (v: string) => validResult
export function validateUrl(s: string): validResult {
let url;
try {
url = new URL(s);
return {
ok: url.protocol === "http:" || url.protocol === "https:",
error: 'Invalid URL protocol',
};
} catch {
return {ok: false, error: 'Invalid URL'};
}
}
export function validateSelector(s: string): validResult {
try {
document.createDocumentFragment().querySelector(s);
return {ok: true}
} catch {
return {ok: false, error: 'Invalid selector'};
}
}
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'
}
}

View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})