@vevedh/create-feathers-app 0.0.0-stage → 0.1.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +79 -2
- package/bin/create-feathers-app.mjs +44 -0
- package/lib/planner/options.mjs +74 -0
- package/lib/planner/plan.mjs +107 -0
- package/lib/scaffold.mjs +300 -0
- package/lib/services.mjs +110 -0
- package/package.json +32 -4
- package/templates/common/dev-all-quasar-ssr.mjs +37 -0
- package/templates/common/dev-all.mjs +46 -0
- package/templates/daisyui-ssg/.env.example +6 -0
- package/templates/daisyui-ssg/README.md +21 -0
- package/templates/daisyui-ssg/api/server.mjs +45 -0
- package/templates/daisyui-ssg/api/services/health/health.class.mjs +3 -0
- package/templates/daisyui-ssg/api/services/health/health.service.mjs +4 -0
- package/templates/daisyui-ssg/api/services/notes/notes.class.mjs +25 -0
- package/templates/daisyui-ssg/api/services/notes/notes.service.mjs +10 -0
- package/templates/daisyui-ssg/gitignore.template +36 -0
- package/templates/daisyui-ssg/index.html +2 -0
- package/templates/daisyui-ssg/package.json +39 -0
- package/templates/daisyui-ssg/scripts/prerender.mjs +14 -0
- package/templates/daisyui-ssg/scripts/serve-static.mjs +32 -0
- package/templates/daisyui-ssg/src/App.vue +14 -0
- package/templates/daisyui-ssg/src/components/NotesPanel.vue +22 -0
- package/templates/daisyui-ssg/src/entry-client.ts +9 -0
- package/templates/daisyui-ssg/src/entry-server.ts +9 -0
- package/templates/daisyui-ssg/src/stores/notes.ts +46 -0
- package/templates/daisyui-ssg/src/style.css +5 -0
- package/templates/daisyui-ssg/src/vite-env.d.ts +1 -0
- package/templates/daisyui-ssg/tsconfig.json +4 -0
- package/templates/daisyui-ssg/vite.config.ts +6 -0
- package/templates/daisyui-ssr/.env.example +6 -0
- package/templates/daisyui-ssr/README.md +21 -0
- package/templates/daisyui-ssr/api/server.mjs +45 -0
- package/templates/daisyui-ssr/api/services/health/health.class.mjs +3 -0
- package/templates/daisyui-ssr/api/services/health/health.service.mjs +4 -0
- package/templates/daisyui-ssr/api/services/notes/notes.class.mjs +25 -0
- package/templates/daisyui-ssr/api/services/notes/notes.service.mjs +10 -0
- package/templates/daisyui-ssr/gitignore.template +36 -0
- package/templates/daisyui-ssr/index.html +2 -0
- package/templates/daisyui-ssr/package.json +39 -0
- package/templates/daisyui-ssr/scripts/serve-ssr.mjs +47 -0
- package/templates/daisyui-ssr/src/App.vue +14 -0
- package/templates/daisyui-ssr/src/components/NotesPanel.vue +22 -0
- package/templates/daisyui-ssr/src/entry-client.ts +9 -0
- package/templates/daisyui-ssr/src/entry-server.ts +9 -0
- package/templates/daisyui-ssr/src/stores/notes.ts +46 -0
- package/templates/daisyui-ssr/src/style.css +5 -0
- package/templates/daisyui-ssr/src/vite-env.d.ts +1 -0
- package/templates/daisyui-ssr/tsconfig.json +4 -0
- package/templates/daisyui-ssr/vite.config.ts +7 -0
- package/templates/quasar-ssr/.env.example +11 -0
- package/templates/quasar-ssr/api/server.mjs +45 -0
- package/templates/quasar-ssr/api/services/health/health.class.mjs +3 -0
- package/templates/quasar-ssr/api/services/health/health.service.mjs +4 -0
- package/templates/quasar-ssr/api/services/notes/notes.class.mjs +25 -0
- package/templates/quasar-ssr/api/services/notes/notes.service.mjs +10 -0
- package/templates/quasar-ssr/gitignore.template +36 -0
- package/templates/quasar-ssr/index.html +12 -0
- package/templates/quasar-ssr/quasar.config.ts +27 -0
- package/templates/quasar-ssr/scripts/edge-proxy.mjs +30 -0
- package/templates/quasar-ssr/scripts/edge-routing.mjs +14 -0
- package/templates/quasar-ssr/src/App.vue +1 -0
- package/templates/quasar-ssr/src/css/app.css +20 -0
- package/templates/quasar-ssr/src/pages/IndexPage.vue +146 -0
- package/templates/quasar-ssr/src/pages/NotFoundPage.vue +11 -0
- package/templates/quasar-ssr/src/router/index.ts +8 -0
- package/templates/quasar-ssr/src/router/routes.ts +6 -0
- package/templates/quasar-ssr/src/stores/index.ts +4 -0
- package/templates/quasar-ssr/src/stores/notes.ts +73 -0
- package/templates/quasar-ssr/src-ssr/middlewares/render.ts +15 -0
- package/templates/quasar-ssr/src-ssr/server.ts +42 -0
- package/templates/quasar-ssr/tsconfig.json +4 -0
- package/templates/spa/common/.env.example +8 -0
- package/templates/spa/common/api/server.mjs +46 -0
- package/templates/spa/common/api/services/health/health.class.mjs +3 -0
- package/templates/spa/common/api/services/health/health.service.mjs +4 -0
- package/templates/spa/common/api/services/notes/notes.class.mjs +25 -0
- package/templates/spa/common/api/services/notes/notes.service.mjs +10 -0
- package/templates/spa/common/gitignore.template +39 -0
- package/templates/spa/common/index.html +3 -0
- package/templates/spa/common/src/components/NotesPanel.vue +25 -0
- package/templates/spa/common/src/stores/notes.ts +105 -0
- package/templates/spa/common/src/vite-env.d.ts +1 -0
- package/templates/spa/common/tsconfig.json +4 -0
- package/templates/spa/daisyui/src/App.vue +2 -0
- package/templates/spa/daisyui/src/components/NotesPanel.vue +39 -0
- package/templates/spa/daisyui/src/main.ts +5 -0
- package/templates/spa/daisyui/src/style.css +5 -0
- package/templates/spa/daisyui/vite.config.ts +8 -0
- package/templates/spa/quasar2/src/App.vue +3 -0
- package/templates/spa/quasar2/src/components/NotesPanel.vue +41 -0
- package/templates/spa/quasar2/src/main.ts +7 -0
- package/templates/spa/quasar2/src/style.css +1 -0
- package/templates/spa/quasar2/vite.config.ts +8 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @quasar/app-vite v2 SSR lifecycle: serveStaticContent configures the Express app.
|
|
2
|
+
// This runtime is independent of the development-only Vite Feathers plugin.
|
|
3
|
+
import express from 'express'
|
|
4
|
+
import {
|
|
5
|
+
ssrCreate, ssrListen, ssrClose, ssrServeStaticContent, ssrRenderPreloadTag,
|
|
6
|
+
} from 'quasar/wrappers'
|
|
7
|
+
|
|
8
|
+
export const create = ssrCreate(() => {
|
|
9
|
+
const app = express()
|
|
10
|
+
app.disable('x-powered-by')
|
|
11
|
+
return app
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
// Quasar CLI v2 does not pass `isReady` to ssrListen.
|
|
15
|
+
// The CLI provides an optional HTTPS development app; bind only to loopback.
|
|
16
|
+
export const listen = ssrListen(({ app, devHttpsApp, port }) => {
|
|
17
|
+
const server = devHttpsApp || app
|
|
18
|
+
return server.listen(port, '127.0.0.1')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const close = ssrClose(({ listenResult }) => new Promise<void>((resolve, reject) => {
|
|
22
|
+
listenResult.close((error: Error | undefined) => error ? reject(error) : resolve())
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const maxAge = process.env.DEV ? 0 : 86_400_000
|
|
26
|
+
// Quasar v2 calls this once with { app, resolve }; return a function to mount
|
|
27
|
+
// public assets. The old `(path, opts) => express.static(path)` signature was
|
|
28
|
+
// removed in app-vite 2 and raised `root path must be a string` at runtime.
|
|
29
|
+
export const serveStaticContent = ssrServeStaticContent(({ app, resolve }) => {
|
|
30
|
+
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
|
|
31
|
+
const publicFolder = resolve.public(pathToServe)
|
|
32
|
+
const url = resolve.urlPath(urlPath)
|
|
33
|
+
app.use(url, express.static(publicFolder, { maxAge, ...opts }))
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export const renderPreloadTag = ssrRenderPreloadTag((file) => {
|
|
38
|
+
if (/\.js$/.test(file)) return `<link rel="modulepreload" href="${file}" crossorigin>`
|
|
39
|
+
if (/\.css$/.test(file)) return `<link rel="stylesheet" href="${file}">`
|
|
40
|
+
if (/\.woff2?$/.test(file)) return `<link rel="preload" href="${file}" as="font" crossorigin>`
|
|
41
|
+
return ''
|
|
42
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Local demonstration only. No secrets or private addresses.
|
|
2
|
+
FEATHERS_ADAPTER=koa
|
|
3
|
+
FEATHERS_API_PORT=3030
|
|
4
|
+
FEATHERS_SOCKET_PORT=3031
|
|
5
|
+
FEATHERS_SOCKETIO=0
|
|
6
|
+
VITE_FEATHERS_REALTIME=0
|
|
7
|
+
FEATHERS_PUBLIC_ORIGIN=http://127.0.0.1:5173
|
|
8
|
+
FEATHERS_PREVIEW_ORIGIN=http://127.0.0.1:4173
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { createFeathersRuntime } from '@vevedh/vite-plugin-feathersjs'
|
|
3
|
+
const adapter = process.env.FEATHERS_ADAPTER === 'express' ? 'express' : 'koa'
|
|
4
|
+
const port = Number(process.env.FEATHERS_API_PORT || 3030)
|
|
5
|
+
const wsPort = Number(process.env.FEATHERS_SOCKET_PORT || 3031)
|
|
6
|
+
const realtime = process.env.FEATHERS_SOCKETIO === '1'
|
|
7
|
+
const origin = process.env.FEATHERS_PUBLIC_ORIGIN || 'http://127.0.0.1:5173'
|
|
8
|
+
const previewOrigin = process.env.FEATHERS_PREVIEW_ORIGIN || 'http://127.0.0.1:4173'
|
|
9
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535 || !Number.isInteger(wsPort) || wsPort === port) throw new Error('Invalid port configuration')
|
|
10
|
+
const modules = [
|
|
11
|
+
new URL('./services/health/health.service.mjs', import.meta.url),
|
|
12
|
+
new URL('./services/notes/notes.service.mjs', import.meta.url),
|
|
13
|
+
]
|
|
14
|
+
const runtime = await createFeathersRuntime(modules.map(String), file => import(file), {
|
|
15
|
+
serviceFiles: [], adapter, prefix: '/api', cors: false,
|
|
16
|
+
socketio: realtime ? { port: wsPort, origins: [...new Set([origin, previewOrigin])], host: '127.0.0.1' } : false,
|
|
17
|
+
})
|
|
18
|
+
await runtime.start()
|
|
19
|
+
const httpServer = createServer((req, res) => {
|
|
20
|
+
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
|
21
|
+
if (pathname !== '/api' && !pathname.startsWith('/api/')) {
|
|
22
|
+
res.writeHead(404, { 'content-type': 'application/json' }); res.end('{"error":"Not found"}'); return
|
|
23
|
+
}
|
|
24
|
+
// The Feathers transport is mounted at /api, while its services register as health/notes.
|
|
25
|
+
req.url = (req.url || '/').slice(4) || '/'
|
|
26
|
+
runtime.handler(req, res, error => {
|
|
27
|
+
if (res.headersSent) { res.end(); return }
|
|
28
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
29
|
+
res.end(JSON.stringify({ error: 'Internal server error' }))
|
|
30
|
+
if (error) console.error('[feathers] request failed')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
httpServer.once('error', reject)
|
|
35
|
+
httpServer.listen(port, '127.0.0.1', resolve)
|
|
36
|
+
})
|
|
37
|
+
console.log(`Feathers ${adapter} API listening on http://127.0.0.1:${port}/api (socketio=${realtime})`)
|
|
38
|
+
let stopping = false
|
|
39
|
+
async function close() {
|
|
40
|
+
if (stopping) return
|
|
41
|
+
stopping = true
|
|
42
|
+
await new Promise(resolve => httpServer.close(resolve))
|
|
43
|
+
await runtime.close()
|
|
44
|
+
}
|
|
45
|
+
process.on('SIGINT', () => { void close().then(() => process.exit(0)) })
|
|
46
|
+
process.on('SIGTERM', () => { void close().then(() => process.exit(0)) })
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NotFound, BadRequest } from '@feathersjs/errors'
|
|
2
|
+
export class NotesService {
|
|
3
|
+
// Feathers v5 exposes service methods through its own service wrapper. The
|
|
4
|
+
// wrapper is not necessarily this class instance, so native #private slots
|
|
5
|
+
// cannot be read through an unbound method call. Bind to the owning instance.
|
|
6
|
+
// Keep this per-service-instance: never hoist records or IDs to module scope.
|
|
7
|
+
constructor() {
|
|
8
|
+
for (const name of ['find', 'get', 'create', 'patch', 'remove']) {
|
|
9
|
+
this[name] = this[name].bind(this)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
#records = new Map([[1, { id: 1, title: 'Hello from FeathersJS v5' }]])
|
|
13
|
+
#nextId = 2
|
|
14
|
+
#id(value) { const n = Number(value); if (!Number.isSafeInteger(n) || n < 1) throw new BadRequest('Invalid note ID'); return n }
|
|
15
|
+
#title(value) {
|
|
16
|
+
const title = value?.title
|
|
17
|
+
if (typeof title !== 'string' || title.trim().length < 1 || title.length > 120) throw new BadRequest('Invalid note title')
|
|
18
|
+
return title.trim()
|
|
19
|
+
}
|
|
20
|
+
async find() { return [...this.#records.values()].map(note => ({ ...note })) }
|
|
21
|
+
async get(id) { const note = this.#records.get(this.#id(id)); if (!note) throw new NotFound('Note not found'); return { ...note } }
|
|
22
|
+
async create(data) { const note = { id: this.#nextId++, title: this.#title(data) }; this.#records.set(note.id, note); return { ...note } }
|
|
23
|
+
async patch(id, data) { const key = this.#id(id); if (!this.#records.has(key)) throw new NotFound('Note not found'); const note = { id: key, title: this.#title(data) }; this.#records.set(key, note); return { ...note } }
|
|
24
|
+
async remove(id) { const key = this.#id(id); const note = this.#records.get(key); if (!note) throw new NotFound('Note not found'); this.#records.delete(key); return { ...note } }
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NotesService } from './notes.class.mjs'
|
|
2
|
+
export default function register(app) {
|
|
3
|
+
app.use('notes', new NotesService(), { methods: ['find', 'get', 'create', 'patch', 'remove'] })
|
|
4
|
+
const service = app.service('notes')
|
|
5
|
+
// Demo-only channel: keep the entire stack loopback-only.
|
|
6
|
+
if (typeof service.publish === 'function') {
|
|
7
|
+
app.on('connection', connection => app.channel('local-demo').join(connection))
|
|
8
|
+
service.publish(() => app.channel('local-demo'))
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.log
|
|
6
|
+
coverage/
|
|
7
|
+
test-results/
|
|
8
|
+
playwright-report/
|
|
9
|
+
.agents/
|
|
10
|
+
AGENTS.md
|
|
11
|
+
SKILLS.md
|
|
12
|
+
*.prompt.md
|
|
13
|
+
.ai/
|
|
14
|
+
.codex/
|
|
15
|
+
.cursor/
|
|
16
|
+
.claude/
|
|
17
|
+
.mcp.json
|
|
18
|
+
.vscode/mcp.json
|
|
19
|
+
docs-private/
|
|
20
|
+
|
|
21
|
+
# Development-only configuration and internal artifacts
|
|
22
|
+
.agents/
|
|
23
|
+
.agent/
|
|
24
|
+
.ai/
|
|
25
|
+
.codex/
|
|
26
|
+
.claude/
|
|
27
|
+
.cursor/
|
|
28
|
+
.gemini/
|
|
29
|
+
.windsurf/
|
|
30
|
+
.ecc/
|
|
31
|
+
.mcp.json
|
|
32
|
+
.vscode/mcp.json
|
|
33
|
+
AGENTS.md
|
|
34
|
+
SKILLS.md
|
|
35
|
+
CLAUDE.md
|
|
36
|
+
GEMINI.md
|
|
37
|
+
*.prompt.md
|
|
38
|
+
docs-private/
|
|
39
|
+
.npmrc
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="fr"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>R17 Feathers Console</title><meta name="r17-example" content="__R17_CASE__"><link rel="icon" href="data:,"></head>
|
|
3
|
+
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import { useNotesStore } from '../stores/notes'
|
|
4
|
+
const store = useNotesStore()
|
|
5
|
+
const title = ref('')
|
|
6
|
+
onMounted(() => { void store.load(); store.connectRealtime() })
|
|
7
|
+
onUnmounted(() => store.disconnectRealtime())
|
|
8
|
+
async function submit() {
|
|
9
|
+
await store.create(title.value)
|
|
10
|
+
if (!store.error) title.value = ''
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
<template>
|
|
14
|
+
<section aria-labelledby="notes-heading" class="notes-panel">
|
|
15
|
+
<h2 id="notes-heading">Service Feathers : notes</h2>
|
|
16
|
+
<p data-testid="realtime-status">{{ store.realtimeConnected ? 'Temps réel connecté' : 'REST actif' }}</p>
|
|
17
|
+
<p v-if="store.error" role="alert">{{ store.error }}</p>
|
|
18
|
+
<form @submit.prevent="submit"><label for="note-title">Nouvelle note</label>
|
|
19
|
+
<input id="note-title" v-model="title" aria-label="Titre de la nouvelle note" maxlength="120" required />
|
|
20
|
+
<button type="submit" :disabled="store.loading || !title.trim()">Créer une note</button>
|
|
21
|
+
</form>
|
|
22
|
+
<p data-testid="notes-count">{{ store.notes.length }} note(s)</p>
|
|
23
|
+
<ul data-testid="notes"><li v-for="note in store.notes" :key="note.id">{{ note.title }}</li></ul>
|
|
24
|
+
</section>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { io, type Socket } from 'socket.io-client'
|
|
4
|
+
|
|
5
|
+
export interface Note { id: number; title: string }
|
|
6
|
+
const realtime = import.meta.env.VITE_FEATHERS_REALTIME === '1'
|
|
7
|
+
|
|
8
|
+
function isNote(value: unknown): value is Note {
|
|
9
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
10
|
+
const record = value as Record<string, unknown>
|
|
11
|
+
return typeof record.id === 'number' && Number.isSafeInteger(record.id) && record.id > 0
|
|
12
|
+
&& typeof record.title === 'string' && record.title.length <= 120
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const useNotesStore = defineStore('notes', () => {
|
|
16
|
+
const notes = ref<Note[]>([])
|
|
17
|
+
const loading = ref(false)
|
|
18
|
+
const error = ref('')
|
|
19
|
+
const apiAvailable = ref(false)
|
|
20
|
+
const apiChecked = ref(false)
|
|
21
|
+
const online = ref(typeof navigator === 'undefined' ? true : navigator.onLine)
|
|
22
|
+
const realtimeConnected = ref(false)
|
|
23
|
+
let socket: Socket | undefined
|
|
24
|
+
|
|
25
|
+
function explainError(cause: unknown): string {
|
|
26
|
+
if (!online.value) return 'Hors ligne : l’interface reste disponible, mais les notes nécessitent la connexion à l’API.'
|
|
27
|
+
if (cause instanceof Error && /^HTTP (404|502|503)$/.test(cause.message)) {
|
|
28
|
+
return 'API Feathers indisponible. En prévisualisation, lancer aussi le serveur API local (pnpm run dev:api).'
|
|
29
|
+
}
|
|
30
|
+
return 'Connexion à l’API impossible. Vérifier le serveur Feathers et le proxy /api.'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function load(): Promise<void> {
|
|
34
|
+
if (!online.value) {
|
|
35
|
+
apiAvailable.value = false
|
|
36
|
+
apiChecked.value = true
|
|
37
|
+
error.value = explainError(new Error('offline'))
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch('/api/notes', {
|
|
42
|
+
headers: { Accept: 'application/json' }, cache: 'no-store',
|
|
43
|
+
})
|
|
44
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
45
|
+
const payload: unknown = await response.json()
|
|
46
|
+
if (!Array.isArray(payload) || !payload.every(isNote)) throw new Error('Invalid notes response')
|
|
47
|
+
notes.value = payload
|
|
48
|
+
apiAvailable.value = true
|
|
49
|
+
error.value = ''
|
|
50
|
+
} catch (cause: unknown) {
|
|
51
|
+
apiAvailable.value = false
|
|
52
|
+
error.value = explainError(cause)
|
|
53
|
+
} finally {
|
|
54
|
+
apiChecked.value = true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function create(title: string): Promise<void> {
|
|
59
|
+
const value = title.trim()
|
|
60
|
+
if (loading.value || !apiAvailable.value || !online.value || value.length < 1 || value.length > 120) return
|
|
61
|
+
loading.value = true
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch('/api/notes', {
|
|
64
|
+
method: 'POST', headers: { 'content-type': 'application/json', Accept: 'application/json' },
|
|
65
|
+
cache: 'no-store', body: JSON.stringify({ title: value }),
|
|
66
|
+
})
|
|
67
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
68
|
+
await load()
|
|
69
|
+
} catch (cause: unknown) {
|
|
70
|
+
apiAvailable.value = false
|
|
71
|
+
error.value = explainError(cause)
|
|
72
|
+
// Never retry POST automatically: the server may have created the note.
|
|
73
|
+
} finally {
|
|
74
|
+
loading.value = false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setConnectivity(connected: boolean): void {
|
|
79
|
+
online.value = connected
|
|
80
|
+
if (!connected) {
|
|
81
|
+
apiAvailable.value = false
|
|
82
|
+
error.value = explainError(new Error('offline'))
|
|
83
|
+
socket?.disconnect()
|
|
84
|
+
realtimeConnected.value = false
|
|
85
|
+
} else {
|
|
86
|
+
void load()
|
|
87
|
+
socket?.connect()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function connectRealtime(): void {
|
|
92
|
+
if (!realtime || typeof window === 'undefined' || socket || !online.value) return
|
|
93
|
+
socket = io(window.location.origin, { path: '/socket.io', transports: ['websocket'] })
|
|
94
|
+
socket.on('connect', () => { realtimeConnected.value = true })
|
|
95
|
+
socket.on('disconnect', () => { realtimeConnected.value = false })
|
|
96
|
+
socket.on('notes created', () => { void load() })
|
|
97
|
+
}
|
|
98
|
+
function disconnectRealtime(): void {
|
|
99
|
+
socket?.disconnect()
|
|
100
|
+
socket = undefined
|
|
101
|
+
realtimeConnected.value = false
|
|
102
|
+
}
|
|
103
|
+
return { notes, loading, error, apiAvailable, apiChecked, online,
|
|
104
|
+
realtimeConnected, load, create, setConnectivity, connectRealtime, disconnectRealtime }
|
|
105
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "types": ["vite/client"], "strict": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "isolatedModules": true, "skipLibCheck": true, "noEmit": true, "jsx": "preserve" },
|
|
3
|
+
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
|
4
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<script setup lang="ts">import NotesPanel from './components/NotesPanel.vue'</script>
|
|
2
|
+
<template><main class="min-h-screen bg-base-200 p-6"><div class="max-w-4xl mx-auto space-y-6"><header class="navbar bg-base-100 rounded-box px-6"><div class="text-xl font-bold">Feathers Console — daisyUI SPA</div></header><h1 class="text-3xl font-semibold">Vue 3, daisyUI et FeathersJS v5</h1><p>Le service Feathers est exécuté dans un processus séparé.</p><NotesPanel /></div></main></template>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import { useNotesStore } from '../stores/notes'
|
|
4
|
+
const store = useNotesStore()
|
|
5
|
+
const title = ref('')
|
|
6
|
+
const onOnline = (): void => { store.setConnectivity(true) }
|
|
7
|
+
const onOffline = (): void => { store.setConnectivity(false) }
|
|
8
|
+
onMounted(() => {
|
|
9
|
+
void store.load()
|
|
10
|
+
store.connectRealtime()
|
|
11
|
+
window.addEventListener('online', onOnline)
|
|
12
|
+
window.addEventListener('offline', onOffline)
|
|
13
|
+
})
|
|
14
|
+
onUnmounted(() => {
|
|
15
|
+
store.disconnectRealtime()
|
|
16
|
+
window.removeEventListener('online', onOnline)
|
|
17
|
+
window.removeEventListener('offline', onOffline)
|
|
18
|
+
})
|
|
19
|
+
async function submit(): Promise<void> {
|
|
20
|
+
await store.create(title.value)
|
|
21
|
+
if (!store.error) title.value = ''
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
<template>
|
|
25
|
+
<section class="card bg-base-100 border border-base-300 shadow-lg"><div class="card-body">
|
|
26
|
+
<h2 class="card-title">Notes FeathersJS</h2>
|
|
27
|
+
<span class="badge badge-outline" data-testid="realtime-status" role="status" aria-live="polite">{{ !store.online ? 'Hors ligne' : !store.apiChecked ? 'Connexion à l’API…' : !store.apiAvailable ? 'API indisponible' : store.realtimeConnected ? 'Temps réel connecté' : 'REST disponible' }}</span>
|
|
28
|
+
<div v-if="store.error" class="alert alert-warning" role="alert">{{ store.error }}</div>
|
|
29
|
+
<form class="flex gap-3 flex-wrap" @submit.prevent="submit">
|
|
30
|
+
<label class="input flex-1"><span class="label">Titre</span><input v-model="title" aria-label="Titre de la nouvelle note" maxlength="120" required /></label>
|
|
31
|
+
<button class="btn btn-primary" type="submit" :disabled="store.loading || !store.online || !store.apiAvailable || !title.trim()">Créer une note</button>
|
|
32
|
+
</form>
|
|
33
|
+
<button v-if="store.online && !store.apiAvailable" class="btn btn-outline btn-sm" type="button" @click="store.load()">Réessayer la connexion</button>
|
|
34
|
+
<p v-if="store.apiAvailable" data-testid="notes-count">{{ store.notes.length }} note(s)</p>
|
|
35
|
+
<p v-else-if="store.notes.length">Les notes déjà affichées peuvent être périmées.</p>
|
|
36
|
+
<p v-else>Les notes seront visibles lorsque l’API sera disponible.</p>
|
|
37
|
+
<ul data-testid="notes" class="list bg-base-200 rounded-box"><li v-for="note in store.notes" :key="note.id" class="list-row">{{ note.title }}</li></ul>
|
|
38
|
+
</div></section>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [vue(), tailwindcss()],
|
|
6
|
+
server: { host: '127.0.0.1', port: 5173, strictPort: true,
|
|
7
|
+
proxy: { '/api': 'http://127.0.0.1:3030', '/socket.io': { target: 'ws://127.0.0.1:3031', ws: true } } },
|
|
8
|
+
})
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<script setup lang="ts">import NotesPanel from './components/NotesPanel.vue'</script>
|
|
2
|
+
<template><q-layout view="hHh lpR fFf"><q-header class="bg-primary text-white"><q-toolbar><q-toolbar-title>Feathers Console — Quasar 2 SPA</q-toolbar-title></q-toolbar></q-header>
|
|
3
|
+
<q-page-container><q-page padding><main class="q-mx-auto" style="max-width: 920px"><h1 class="text-h4">Vue 3, Quasar 2 et FeathersJS v5</h1><p>Le service Feathers est exécuté dans un processus séparé.</p><NotesPanel /></main></q-page></q-page-container></q-layout></template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import { useNotesStore } from '../stores/notes'
|
|
4
|
+
const store = useNotesStore()
|
|
5
|
+
const title = ref('')
|
|
6
|
+
const onOnline = (): void => { store.setConnectivity(true) }
|
|
7
|
+
const onOffline = (): void => { store.setConnectivity(false) }
|
|
8
|
+
onMounted(() => {
|
|
9
|
+
void store.load()
|
|
10
|
+
store.connectRealtime()
|
|
11
|
+
window.addEventListener('online', onOnline)
|
|
12
|
+
window.addEventListener('offline', onOffline)
|
|
13
|
+
})
|
|
14
|
+
onUnmounted(() => {
|
|
15
|
+
store.disconnectRealtime()
|
|
16
|
+
window.removeEventListener('online', onOnline)
|
|
17
|
+
window.removeEventListener('offline', onOffline)
|
|
18
|
+
})
|
|
19
|
+
async function submit(): Promise<void> {
|
|
20
|
+
await store.create(title.value)
|
|
21
|
+
if (!store.error) title.value = ''
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
<template>
|
|
25
|
+
<q-card flat bordered class="q-pa-lg">
|
|
26
|
+
<q-card-section><div class="text-h6">Notes FeathersJS</div>
|
|
27
|
+
<div data-testid="realtime-status" role="status" aria-live="polite">{{ !store.online ? 'Hors ligne' : !store.apiChecked ? 'Connexion à l’API…' : !store.apiAvailable ? 'API indisponible' : store.realtimeConnected ? 'Temps réel connecté' : 'REST disponible' }}</div></q-card-section>
|
|
28
|
+
<q-banner v-if="store.error" role="alert">{{ store.error }}</q-banner>
|
|
29
|
+
<q-card-section>
|
|
30
|
+
<form class="row items-center q-gutter-sm" @submit.prevent="submit">
|
|
31
|
+
<q-input v-model="title" label="Titre de la nouvelle note" data-testid="note-title-input" maxlength="120" outlined dense class="col" />
|
|
32
|
+
<q-btn label="Créer une note" type="submit" color="primary" :disable="store.loading || !store.online || !store.apiAvailable || !title.trim()"/>
|
|
33
|
+
</form>
|
|
34
|
+
<q-btn v-if="store.online && !store.apiAvailable" flat label="Réessayer la connexion" @click="store.load()" />
|
|
35
|
+
<p v-if="store.apiAvailable" data-testid="notes-count" class="q-mt-md">{{ store.notes.length }} note(s)</p>
|
|
36
|
+
<p v-else-if="store.notes.length">Les notes déjà affichées peuvent être périmées.</p>
|
|
37
|
+
<p v-else>Les notes seront visibles lorsque l’API sera disponible.</p>
|
|
38
|
+
<q-list bordered separator data-testid="notes"><q-item v-for="note in store.notes" :key="note.id"><q-item-section>{{ note.title }}</q-item-section></q-item></q-list>
|
|
39
|
+
</q-card-section>
|
|
40
|
+
</q-card>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
body { background: #f7f8fc; }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
3
|
+
import { quasar } from '@quasar/vite-plugin'
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [vue(), quasar()],
|
|
6
|
+
server: { host: '127.0.0.1', port: 5173, strictPort: true,
|
|
7
|
+
proxy: { '/api': 'http://127.0.0.1:3030', '/socket.io': { target: 'ws://127.0.0.1:3031', ws: true } } },
|
|
8
|
+
})
|