first commit

This commit is contained in:
2025-08-01 06:05:06 +02:00
commit e2c546527f
44 changed files with 11845 additions and 0 deletions

9
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
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

35
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# 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
# Environment variables
.env
.env.*
!.env.example
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

16
frontend/.prettierrc.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"vueIndentScriptAndStyle": true,
"plugins": [
"prettier-plugin-organize-imports",
"prettier-plugin-tailwindcss"
]
}

Binary file not shown.

1
frontend/.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://localhost:4173',
},
})

View File

@@ -0,0 +1,8 @@
// https://on.cypress.io/api
describe('My First Test', () => {
it('visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'You did it!')
})
})

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,39 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
export {}

View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["./e2e/**/*", "./support/**/*"],
"exclude": ["./support/component.*"],
"compilerOptions": {
"isolatedModules": false,
"types": ["cypress"]
}
}

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

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

41
frontend/eslint.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import pluginCypress from 'eslint-plugin-cypress'
import pluginOxlint from 'eslint-plugin-oxlint'
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}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
{
...pluginCypress.configs.recommended,
files: [
'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}',
'cypress/support/**/*.{js,ts,jsx,tsx}'
],
},
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
)

6
frontend/example.env Normal file
View File

@@ -0,0 +1,6 @@
# Backend API URL (used if you need to override the default proxy settings in vite.config.ts)
# VITE_API_BASE_URL=http://localhost:8000
# Note: By default, the frontend proxies API requests to http://localhost:8000
# as configured in vite.config.ts. You only need to set VITE_API_BASE_URL
# if you want to use a different backend URL.

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="/src/style.css" rel="stylesheet">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

62
frontend/package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "owly-news-summariser",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev:watch": "vite build --watch",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"prepare": "cypress install",
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:oxlint lint:eslint",
"format": "prettier --write src/"
},
"dependencies": {
"idb-keyval": "^6.2.2",
"pinia": "^3.0.3",
"vue": "^3.5.17"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.15.32",
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue-jsx": "^5.0.0",
"@vitest/eslint-plugin": "^1.2.7",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.1",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"cypress": "^14.5.0",
"eslint": "^9.29.0",
"eslint-plugin-cypress": "^5.1.0",
"eslint-plugin-oxlint": "~1.1.0",
"eslint-plugin-vue": "~10.2.0",
"jiti": "^2.4.2",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.1.0",
"prettier": "3.5.3",
"prettier-plugin-organize-imports": "^4.2.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"rolldown": "1.0.0-beta.29",
"start-server-and-test": "^2.0.12",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.0",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-pwa": "^0.18.0",
"vite-plugin-vue-devtools": "^7.7.7",
"vitest": "^3.2.4",
"vue-tsc": "^2.2.10"
},
"packageManager": "yarn@4.9.2"
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

43
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import {onMounted, ref} from 'vue';
import {useNews} from './stores/useNews';
import FeedManager from './components/FeedManager.vue';
import CronSlider from './components/CronSlider.vue';
import SyncButton from './components/SyncButton.vue';
import ModelStatus from './components/ModelStatus.vue';
const news = useNews();
const filters = ref({country: 'DE'});
onMounted(async () => {
await news.loadLastSync();
await news.sync(filters.value);
});
</script>
<template>
<main class="max-w-4xl mx-auto p-4 space-y-6">
<h1 class="text-2xl font-bold">📰 Local News Summariser</h1>
<div class="grid md:grid-cols-3 gap-4">
<CronSlider/>
<SyncButton/>
<ModelStatus/>
</div>
<FeedManager/>
<section v-if="news.offline" class="p-2 bg-yellow-100 border-l-4 border-yellow-500">
Offline Datenstand: {{ new Date(news.lastSync).toLocaleString() }}
</section>
<article v-for="a in news.articles" :key="a.id" class="bg-white rounded p-4 shadow">
<h2 class="font-semibold">{{ a.title }}</h2>
<p class="text-sm text-gray-600">{{ new Date(a.published).toLocaleString() }} {{
a.source
}}</p>
<p class="mt-2">{{ a.summary_de }}</p>
<p class="italic mt-2 text-sm text-gray-700">{{ a.summary_en }}</p>
<a :href="a.id" target="_blank" class="text-blue-600 hover:underline">Original </a>
</article>
</main>
</template>

View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import {ref, onMounted} from 'vue';
const hours = ref(1);
const saving = ref(false);
onMounted(async () => {
const h = await fetch('/settings/cron').then(r => r.json());
hours.value = h.hours;
});
async function update() {
saving.value = true;
await fetch('/settings/cron', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hours: hours.value})
});
saving.value = false;
}
</script>
<template>
<div class="p-4 bg-white rounded shadow">
<label class="block font-semibold mb-2">Fetch Interval (Stunden)</label>
<input type="range" min="0.5" max="24" step="0.5" v-model="hours" @change="update"
class="w-full"/>
<p class="text-sm mt-2">{{ hours }} h <span v-if="saving" class="animate-pulse"></span></p>
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import {ref, onMounted} from 'vue';
import {useFeeds} from '../stores/useFeeds';
const feeds = useFeeds();
const country = ref('DE');
const url = ref('');
onMounted(() => feeds.fetch());
async function add() {
if (!url.value.trim()) return;
await feeds.add(country.value, url.value.trim());
url.value = '';
}
</script>
<template>
<div class="bg-white rounded p-4 shadow space-y-2">
<h2 class="font-semibold text-lg">Feeds verwalten</h2>
<div class="flex gap-2">
<select v-model="country" class="border rounded p-1 flex-1">
<option>DE</option>
<option>EU</option>
</select>
<input v-model="url" placeholder="https://…" class="border rounded p-1 flex-[3]"/>
<button @click="add" class="bg-green-600 text-white px-3 rounded">+</button>
</div>
<ul class="list-disc pl-5">
<li v-for="(f, index) in feeds.list" :key="index" class="flex justify-between items-center">
<span>{{ f.country }} {{ f.url }}</span>
<button @click="feeds.remove(f.url)" class="text-red-600"></button>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useModel } from '../stores/useModel';
const model = useModel();
onMounted(async () => {
await model.loadModelStatus();
});
</script>
<template>
<div class="bg-white rounded p-4 shadow">
<h3 class="font-semibold mb-2">Model Status</h3>
<div v-if="model.status === 'loading'" class="text-gray-600">
Loading model information...
</div>
<div v-else-if="model.status === 'error'" class="text-red-600">
Error: {{ model.error }}
</div>
<div v-else>
<div class="flex items-center mb-2">
<span class="font-medium mr-2">Name:</span>
<span>{{ model.name }}</span>
</div>
<div class="flex items-center">
<span class="font-medium mr-2">Status:</span>
<span
:class="{
'text-green-600': model.status === 'ready',
'text-red-600': model.status === 'not available'
}"
>
{{ model.status }}
</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import {useNews} from '../stores/useNews';
const news = useNews();
async function click() {
if (!news.canManualSync()) return;
await fetch('/sync', {method: 'POST'});
await news.sync({country: 'DE'});
}
</script>
<template>
<button :disabled="!news.canManualSync()" @click="click"
class="px-4 py-2 rounded bg-blue-600 disabled:bg-gray-400 text-white">
Sync now
</button>
<p v-if="!news.canManualSync()" class="text-xs text-gray-500 mt-1">
Wait {{ Math.ceil((30 * 60 * 1000 - (Date.now() - news.lastSync)) / 60000) }} min
</p>
</template>

12
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import './assets/main.css';
import {createPinia} from 'pinia';
import {createApp} from 'vue';
import App from "@/App.vue";
const app = createApp(App);
app.use(createPinia());
app.mount('#app');

View File

@@ -0,0 +1,21 @@
import {defineStore} from 'pinia';
export const useFeeds = defineStore('feeds', {
state: () => ({list: [] as { country: string, url: string }[]}),
actions: {
async fetch() {
this.list = await fetch('/feeds').then(r => r.json());
},
async add(country: string, url: string) {
await fetch('/feeds', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({country, url})
});
await this.fetch();
},
async remove(url: string) {
await fetch(`/feeds?url=${encodeURIComponent(url)}`, {method: 'DELETE'});
await this.fetch();
}
}
});

View File

@@ -0,0 +1,28 @@
import {defineStore} from 'pinia';
export const useModel = defineStore('model', {
state: () => ({
name: '',
status: 'loading',
availableModels: [] as string[],
error: null as string | null
}),
actions: {
async loadModelStatus() {
try {
const response = await fetch('/model/status');
if (!response.ok) {
throw new Error('Failed to fetch model status');
}
const data = await response.json();
this.name = data.name;
this.status = data.status;
this.availableModels = data.available_models || [];
this.error = null;
} catch (e) {
this.status = 'error';
this.error = e instanceof Error ? e.message : 'Unknown error';
}
}
}
});

View File

@@ -0,0 +1,43 @@
import {defineStore} from 'pinia';
import {set, get} from 'idb-keyval';
export const useNews = defineStore('news', {
state: () => ({
articles: [] as {
id: string,
published: number,
title: string,
source: string,
summary_de: string,
summary_en: string
}[], lastSync: 0, offline: false
}),
actions: {
async loadLastSync() {
const {ts} = await fetch('/meta/last-sync').then(r => r.json());
this.lastSync = ts * 1000; // store ms
},
canManualSync() {
return Date.now() - this.lastSync > 30 * 60 * 1000; // 30min guard
},
async sync(filters: Record<string, string>) {
try {
if (!this.canManualSync()) throw new Error('Too soon');
const q = new URLSearchParams(filters).toString();
const res = await fetch(`/news?${q}`);
if (!res.ok) throw new Error('network');
const data = await res.json();
this.articles = data;
this.lastSync = Date.now();
await set(JSON.stringify(filters), data);
this.offline = false;
} catch (e) {
const cached = await get(JSON.stringify(filters));
if (cached) {
this.articles = cached;
this.offline = true;
}
}
}
}
});

1
frontend/src/style.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

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/*"]
}
}
}

17
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
],
"compilerOptions": {
"module": "NodeNext"
}
}

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,11 @@
{
"extends": "./tsconfig.app.json",
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
}
}

45
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,45 @@
import {fileURLToPath, URL} from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import {defineConfig} from 'vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import {VitePWA} from "vite-plugin-pwa";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
runtimeCaching: [
{
urlPattern: /\/news\?/,
handler: 'NetworkFirst',
options: {
cacheName: 'news-api',
networkTimeoutSeconds: 3
}
}
]
}
})
],
build: {outDir: 'dist'},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/news': 'http://localhost:8000',
'/meta': 'http://localhost:8000'
}
},
});

14
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)

10078
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff