Files
app-hejyou/setup_auth.mjs
admin e7b4ec571e feat: persönlichere Profilseite + iOS-App-Setup
Profil: Begrüßung in Zielsprache, Kategorie-Punkte-Übersicht,
ruhigerer Header (kein rotierender Avatar/Online-Dot), Notch-Fix
und kompaktere Aktivitäts-Heatmap. Außerdem Capacitor-iOS-Projekt
und diverse Auth/Feed/Audio-Verbesserungen aus dem Premium-Redesign.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:55:13 +02:00

777 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Führe aus: node setup_auth.js
// Im Verzeichnis: /Users/tim/Documents/GitTea/Language
import { writeFileSync, mkdirSync } from 'fs'
import { join } from 'path'
const ROOT = '/Users/tim/Documents/GitTea/Language'
const files = {
// ─── .env (falls noch nicht vorhanden) ───────────────────
'.env': `VITE_DIRECTUS_URL=https://db.hejyou.com
VITE_DIRECTUS_TOKEN=j6YyjhoFidnU3cI3MSrcgTXqO3t2wbZG
`,
// ─── API Service ─────────────────────────────────────────
'src/api/directus.js': `const BASE = import.meta.env.VITE_DIRECTUS_URL
const TOKEN = import.meta.env.VITE_DIRECTUS_TOKEN
const headers = {
'Content-Type': 'application/json',
'Authorization': \`Bearer \${TOKEN}\`,
}
export async function login(email, password) {
const res = await fetch(\`\${BASE}/auth/login\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Login fehlgeschlagen.')
return data.data
}
export async function getMe(userToken) {
const res = await fetch(
\`\${BASE}/users/me?fields=id,username,language_native,language_target\`,
{ headers: { 'Authorization': \`Bearer \${userToken}\` } }
)
const data = await res.json()
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
return data.data
}
export async function registerUser(email, password) {
const res = await fetch(\`\${BASE}/users\`, {
method: 'POST',
headers,
body: JSON.stringify({ email, password }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Registrierung fehlgeschlagen.')
return data.data
}
export async function checkUsername(username) {
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
const res = await fetch(
\`\${BASE}/items/users_language?filter[username_lowercases][_eq]=\${encodeURIComponent(clean)}&fields=id&limit=1\`,
{ headers }
)
const data = await res.json()
return data.data?.length === 0
}
export async function createProfile({ userId, username, nativeLang, targetLang, userToken }) {
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
const authHeaders = userToken
? { 'Content-Type': 'application/json', 'Authorization': \`Bearer \${userToken}\` }
: headers
const profileRes = await fetch(\`\${BASE}/items/users_language\`, {
method: 'POST',
headers,
body: JSON.stringify({ username_public: username, username_lowercases: clean, status: 'published' }),
})
const profileData = await profileRes.json()
if (!profileRes.ok) throw new Error(profileData.errors?.[0]?.message || 'Profil konnte nicht erstellt werden.')
const profileId = profileData.data.id
await fetch(\`\${BASE}/users/\${userId}\`, {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify({ username: profileId, language_native: nativeLang, language_target: targetLang }),
})
await fetch(\`\${BASE}/items/users_language/\${profileId}\`, {
method: 'PATCH',
headers,
body: JSON.stringify({ user: userId }),
})
await fetch(\`\${BASE}/items/learning_pairs\`, {
method: 'POST',
headers,
body: JSON.stringify({
user: profileId,
language_from: nativeLang,
language_to: targetLang,
active: true,
current_level: 1,
points: 0,
}),
})
return profileId
}
export const LANGUAGE_OPTIONS = [
{ id: '88053026-3d7e-4799-b10d-67187f7c1709', label: 'Deutsch', flag: '🇩🇪' },
{ id: '99fbaa9d-3cac-48cb-a5e2-dcb320e913e4', label: 'Englisch', flag: '🇬🇧' },
{ id: '25350b32-e9ab-4fec-946e-c0f11eff70dd', label: 'Schwedisch', flag: '🇸🇪' },
]
`,
// ─── Auth Context ─────────────────────────────────────────
'src/context/AuthContext.jsx': `import { createContext, useContext, useState, useEffect } from 'react'
import { getMe } from '../api/directus'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem('hejyou_token'))
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!token) { setLoading(false); return }
getMe(token)
.then(setUser)
.catch(() => { localStorage.removeItem('hejyou_token'); setToken(null) })
.finally(() => setLoading(false))
}, [token])
const saveToken = (t) => {
localStorage.setItem('hejyou_token', t)
setToken(t)
}
const logout = () => {
localStorage.removeItem('hejyou_token')
setToken(null)
setUser(null)
}
return (
<AuthContext.Provider value={{ token, user, setUser, saveToken, logout, loading }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
`,
// ─── UI Components ────────────────────────────────────────
'src/components/auth/ui.jsx': `import { useState } from 'react'
import styles from './auth.module.css'
export function FormGroup({ label, children }) {
return (
<div className={styles.formGroup}>
{label && <label className={styles.label}>{label}</label>}
{children}
</div>
)
}
export function Input({ className, ...props }) {
return <input className={styles.input} {...props} />
}
export function Select({ children, ...props }) {
return (
<div className={styles.selectWrap}>
<select className={styles.input} {...props}>{children}</select>
<div className={styles.selectArrow} />
</div>
)
}
export function Button({ loading, children, ...props }) {
return (
<button className={styles.btn} {...props}>
{children}
{loading && <span className={styles.spinner} />}
</button>
)
}
export function Alert({ message }) {
if (!message) return null
return <div className={styles.alert}>{message}</div>
}
export function StepDots({ current, total }) {
return (
<div className={styles.stepDots}>
{Array.from({ length: total }).map((_, i) => (
<div
key={i}
className={styles.stepDot}
style={{ background: i === current ? 'var(--accent)' : 'var(--border)', width: i === current ? '18px' : '6px' }}
/>
))}
<span className={styles.stepLabel}>Schritt {current + 1} von {total}</span>
</div>
)
}
`,
// ─── Auth CSS Module ──────────────────────────────────────
'src/components/auth/auth.module.css': `:root {
--bg: #F5F0E8;
--surface: #FFFCF7;
--border: #E2DAD0;
--text: #2C2520;
--muted: #9A8F85;
--accent: #5C7A5E;
--accent-lt: #EAF0EA;
--danger: #C0544A;
--danger-lt: #FBF0EF;
--radius: 14px;
}
.formGroup { margin-bottom: 16px; }
.label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 6px;
}
.input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
font-family: 'DM Sans', sans-serif;
font-size: 15px;
color: var(--text);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
appearance: none;
-webkit-appearance: none;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(92,122,94,0.12);
background: var(--surface);
}
.selectWrap { position: relative; }
.selectArrow {
position: absolute;
right: 14px; top: 50%;
transform: translateY(-50%);
width: 0; height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--muted);
pointer-events: none;
}
.selectWrap .input { padding-right: 36px; cursor: pointer; }
.btn {
width: 100%;
padding: 13px;
margin-top: 8px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
font-family: 'DM Sans', sans-serif;
font-size: 15px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.2s, transform 0.1s;
}
.btn:hover { background: #4a6650; }
.btn:active { transform: scale(0.98); }
.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
.spinner {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.alert {
background: var(--danger-lt);
border: 1px solid #EBCBC8;
border-radius: var(--radius);
padding: 10px 14px;
font-size: 13px;
color: var(--danger);
margin-bottom: 16px;
}
.stepDots {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 24px;
}
.stepDot {
height: 6px;
border-radius: 3px;
transition: all 0.25s ease;
}
.stepLabel {
font-size: 11px;
color: var(--muted);
margin-left: 4px;
}
@keyframes spin { to { transform: rotate(360deg); } }
`,
// ─── Login Form ───────────────────────────────────────────
'src/components/auth/LoginForm.jsx': `import { useState } from 'react'
import { login, getMe } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Button, Alert } from './ui'
export default function LoginForm({ onNeedsProfile }) {
const { saveToken, setUser } = useAuth()
const [email, setEmail] = useState('')
const [pw, setPw] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e?.preventDefault()
if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return }
setError('')
setLoading(true)
try {
const { access_token } = await login(email, pw)
saveToken(access_token)
const me = await getMe(access_token)
setUser(me)
if (!me.username || !me.language_native || !me.language_target) {
onNeedsProfile(me.id, access_token)
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<Alert message={error} />
<FormGroup label="E-Mail">
<Input type="email" placeholder="deine@email.de" value={email}
onChange={e => setEmail(e.target.value)} autoComplete="email" autoFocus />
</FormGroup>
<FormGroup label="Passwort">
<Input type="password" placeholder="••••••••" value={pw}
onChange={e => setPw(e.target.value)} autoComplete="current-password" />
</FormGroup>
<Button type="submit" loading={loading} disabled={loading}>
{loading ? 'Anmelden…' : 'Anmelden'}
</Button>
</form>
)
}
`,
// ─── Register Step 1 ──────────────────────────────────────
'src/components/auth/RegisterStep1.jsx': `import { useState } from 'react'
import { registerUser, login } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Button, Alert, StepDots } from './ui'
export default function RegisterStep1({ onSuccess }) {
const { saveToken } = useAuth()
const [email, setEmail] = useState('')
const [pw, setPw] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e?.preventDefault()
if (!email || !pw) { setError('Bitte alle Felder ausfüllen.'); return }
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
setError('')
setLoading(true)
try {
const newUser = await registerUser(email, pw)
const { access_token } = await login(email, pw)
saveToken(access_token)
onSuccess(newUser.id, access_token)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<StepDots current={0} total={2} />
<Alert message={error} />
<FormGroup label="E-Mail">
<Input type="email" placeholder="deine@email.de" value={email}
onChange={e => setEmail(e.target.value)} autoComplete="email" autoFocus />
</FormGroup>
<FormGroup label="Passwort">
<Input type="password" placeholder="Mindestens 8 Zeichen" value={pw}
onChange={e => setPw(e.target.value)} autoComplete="new-password" />
</FormGroup>
<Button type="submit" loading={loading} disabled={loading}>
{loading ? 'Weiter…' : 'Weiter'}
</Button>
</form>
)
}
`,
// ─── Register Step 2 ──────────────────────────────────────
'src/components/auth/RegisterStep2.jsx': `import { useState, useRef, useCallback } from 'react'
import { checkUsername, createProfile, LANGUAGE_OPTIONS } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
import styles from './auth.module.css'
export default function RegisterStep2({ userId, userToken, onSuccess }) {
const { setUser } = useAuth()
const [username, setUsername] = useState('')
const [nativeLang, setNativeLang] = useState('')
const [targetLang, setTargetLang] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [usernameState, setUsernameState] = useState('idle')
const debounceRef = useRef(null)
const handleUsernameChange = useCallback((val) => {
setUsername(val)
setUsernameState('idle')
clearTimeout(debounceRef.current)
if (val.length < 3) return
setUsernameState('checking')
debounceRef.current = setTimeout(async () => {
try {
const available = await checkUsername(val)
setUsernameState(available ? 'available' : 'taken')
} catch { setUsernameState('idle') }
}, 450)
}, [])
const handleSubmit = async (e) => {
e?.preventDefault()
if (!username) { setError('Bitte einen Username wählen.'); return }
if (usernameState !== 'available') { setError('Bitte einen verfügbaren Username wählen.'); return }
if (!nativeLang) { setError('Bitte Muttersprache wählen.'); return }
if (!targetLang) { setError('Bitte Zielsprache wählen.'); return }
if (nativeLang === targetLang) { setError('Mutter- und Zielsprache dürfen nicht gleich sein.'); return }
setError('')
setLoading(true)
try {
await createProfile({ userId, username, nativeLang, targetLang, userToken })
setUser(prev => ({ ...prev, username: true, language_native: nativeLang, language_target: targetLang }))
onSuccess(username)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const statusColor = { idle: 'var(--muted)', checking: 'var(--muted)', available: 'var(--accent)', taken: 'var(--danger)' }
const statusText = { idle: '', checking: '…', available: '✓ verfügbar', taken: '✕ vergeben' }
return (
<form onSubmit={handleSubmit}>
<StepDots current={1} total={2} />
<Alert message={error} />
<FormGroup label="Username">
<div style={{ position: 'relative' }}>
<Input type="text" placeholder="dein_username" value={username}
onChange={e => handleUsernameChange(e.target.value)}
autoComplete="off" autoFocus style={{ paddingRight: '110px' }} />
{usernameState !== 'idle' && (
<span style={{
position: 'absolute', right: '12px', top: '50%',
transform: 'translateY(-50%)', fontSize: '12px', fontWeight: 500,
color: statusColor[usernameState], whiteSpace: 'nowrap',
}}>
{statusText[usernameState]}
</span>
)}
</div>
</FormGroup>
<FormGroup label="Muttersprache">
<Select value={nativeLang} onChange={e => setNativeLang(e.target.value)}>
<option value="">Bitte wählen…</option>
{LANGUAGE_OPTIONS.map(l => <option key={l.id} value={l.id}>{l.flag} {l.label}</option>)}
</Select>
</FormGroup>
<FormGroup label="Ich möchte lernen">
<Select value={targetLang} onChange={e => setTargetLang(e.target.value)}>
<option value="">Bitte wählen…</option>
{LANGUAGE_OPTIONS.filter(l => l.id !== nativeLang).map(l => <option key={l.id} value={l.id}>{l.flag} {l.label}</option>)}
</Select>
</FormGroup>
<Button type="submit" loading={loading}
disabled={loading || usernameState === 'taken' || usernameState === 'checking'}>
{loading ? 'Speichere…' : 'Profil erstellen'}
</Button>
</form>
)
}
`,
// ─── Auth Screen ──────────────────────────────────────────
'src/components/auth/AuthScreen.jsx': `import { useState } from 'react'
import LoginForm from './LoginForm'
import RegisterStep1 from './RegisterStep1'
import RegisterStep2 from './RegisterStep2'
import styles from './AuthScreen.module.css'
function Brand() {
return (
<div className={styles.brand}>
<div className={styles.brandMark}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="10" />
<path d="M8 12q2-5 4-4t4 4-4 4-4-4" />
</svg>
</div>
<h1 className={styles.brandTitle}>HejYou</h1>
<p className={styles.brandSub}>Sprachen lernen wie ein Kind</p>
</div>
)
}
function ModeToggle({ mode, onChange }) {
return (
<div className={styles.toggle}>
{['login', 'register'].map(m => (
<button key={m} onClick={() => onChange(m)}
className={mode === m ? \`\${styles.toggleBtn} \${styles.toggleBtnActive}\` : styles.toggleBtn}>
{m === 'login' ? 'Anmelden' : 'Registrieren'}
</button>
))}
</div>
)
}
function SuccessScreen({ username }) {
return (
<div className={styles.success}>
<div className={styles.successCheck}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<strong className={styles.successTitle}>Willkommen{username ? \`, \${username}\` : ''}!</strong>
<p className={styles.successSub}>Dein Abenteuer beginnt jetzt.</p>
</div>
)
}
export default function AuthScreen() {
const [mode, setMode] = useState(() => localStorage.getItem('hejyou_last_mode') || 'login')
const [step, setStep] = useState('main')
const [pendingUserId, setPendingUserId] = useState(null)
const [pendingToken, setPendingToken] = useState(null)
const [successName, setSuccessName] = useState('')
const [showToggle, setShowToggle] = useState(true)
const handleModeChange = (m) => {
setMode(m)
localStorage.setItem('hejyou_last_mode', m)
setStep('main')
setShowToggle(true)
}
const handleNeedsProfile = (userId, token) => {
setPendingUserId(userId); setPendingToken(token)
setShowToggle(false); setStep('profile')
}
return (
<div className={styles.page}>
<div className={styles.card}>
<Brand />
{showToggle && step === 'main' && <ModeToggle mode={mode} onChange={handleModeChange} />}
{step === 'main' && mode === 'login' && <LoginForm onNeedsProfile={handleNeedsProfile} />}
{step === 'main' && mode === 'register' && <RegisterStep1 onSuccess={(id, t) => handleNeedsProfile(id, t)} />}
{step === 'profile' && <RegisterStep2 userId={pendingUserId} userToken={pendingToken} onSuccess={(name) => { setSuccessName(name); setStep('success') }} />}
{step === 'success' && <SuccessScreen username={successName} />}
</div>
</div>
)
}
`,
// ─── AuthScreen CSS Module ────────────────────────────────
'src/components/auth/AuthScreen.module.css': `.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--bg, #F5F0E8);
}
.card {
background: var(--surface, #FFFCF7);
border: 1px solid var(--border, #E2DAD0);
border-radius: 24px;
padding: 48px 44px;
width: 100%;
max-width: 420px;
box-shadow: 0 2px 40px rgba(44,37,32,0.06);
animation: fadeUp 0.3s ease;
}
.brand { text-align: center; margin-bottom: 36px; }
.brandMark {
width: 48px; height: 48px;
background: var(--accent, #5C7A5E);
border-radius: 50%;
margin: 0 auto 14px;
display: flex; align-items: center; justify-content: center;
}
.brandTitle {
font-family: 'Lora', serif;
font-size: 22px; font-weight: 500;
letter-spacing: -0.3px;
color: var(--text, #2C2520);
}
.brandSub { font-size: 13px; color: var(--muted, #9A8F85); margin-top: 4px; }
.toggle {
display: flex;
background: var(--bg, #F5F0E8);
border: 1px solid var(--border, #E2DAD0);
border-radius: 10px;
padding: 3px; gap: 3px;
margin-bottom: 32px;
}
.toggleBtn {
flex: 1; padding: 8px;
border: none; border-radius: 8px;
background: transparent;
font-family: 'DM Sans', sans-serif;
font-size: 13px; font-weight: 500;
color: var(--muted, #9A8F85);
cursor: pointer;
transition: all 0.2s;
}
.toggleBtnActive {
background: var(--surface, #FFFCF7);
color: var(--text, #2C2520);
box-shadow: 0 1px 4px rgba(44,37,32,0.08);
}
.success { text-align: center; padding: 24px 0; }
.successCheck {
width: 52px; height: 52px;
background: var(--accent-lt, #EAF0EA);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 16px;
}
.successTitle {
font-family: 'Lora', serif;
font-size: 18px;
display: block; margin-bottom: 8px;
}
.successSub { font-size: 14px; color: var(--muted, #9A8F85); }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
`,
// ─── App.jsx (ersetzt bestehende) ─────────────────────────
'src/App.jsx': `import { AuthProvider, useAuth } from './context/AuthContext'
import AuthScreen from './components/auth/AuthScreen'
function AppContent() {
const { user, loading, logout } = useAuth()
if (loading) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
width: '32px', height: '32px',
border: '2px solid #E2DAD0',
borderTopColor: '#5C7A5E',
borderRadius: '50%',
animation: 'spin 0.7s linear infinite',
}} />
<style>{\`@keyframes spin { to { transform: rotate(360deg); } }\`}</style>
</div>
)
}
// Nicht eingeloggt oder Profil unvollständig → Auth Screen
if (!user || !user.username || !user.language_native || !user.language_target) {
return <AuthScreen />
}
// ─── Eingeloggt → hier kommt dein bestehender App-Content ──
return (
<div>
{/* TODO: Deine bestehenden Pages/Routes hier einbauen */}
<p style={{ padding: 32, fontFamily: 'sans-serif' }}>
Eingeloggt ✓ — hier kommt der Feed.
<button onClick={logout} style={{ marginLeft: 16, cursor: 'pointer' }}>Logout</button>
</p>
</div>
)
}
export default function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
)
}
`,
}
// Erstelle alle Verzeichnisse und schreibe Dateien
const dirs = new Set()
for (const path of Object.keys(files)) {
const parts = path.split('/')
for (let i = 1; i < parts.length; i++) {
dirs.add(join(ROOT, ...parts.slice(0, i)))
}
}
for (const dir of dirs) {
try { mkdirSync(dir, { recursive: true }) } catch {}
}
let written = 0
for (const [path, content] of Object.entries(files)) {
const fullPath = join(ROOT, path)
// .env nur schreiben wenn noch nicht vorhanden
if (path === '.env') {
try {
const existing = await import('fs').then(m => m.readFileSync(fullPath, 'utf8'))
console.log('⏭ .env bereits vorhanden übersprungen')
continue
} catch {}
}
writeFileSync(fullPath, content, 'utf8')
console.log(`${path}`)
written++
}
console.log(\`\nFertig! \${written} Dateien geschrieben.\`)
console.log('\nJetzt starten:')
console.log(' npm install (falls neue Abhängigkeiten fehlen)')
console.log(' npm run dev')