retried with custom-api widgets
This commit is contained in:
@@ -990,6 +990,263 @@ data:
|
||||
}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
# ================================
|
||||
# User Data Management (Notes, Todos, Motivation)
|
||||
# ================================
|
||||
# File structure per user: /data/userdata-{username}.json
|
||||
# {
|
||||
# "notes": "free text...",
|
||||
# "todos": [{"id": "uuid", "text": "...", "done": false, "created": "..."}],
|
||||
# "motivation": ["quote1", "quote2", ...]
|
||||
# }
|
||||
|
||||
import uuid as uuid_lib
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
class TodoItem(BaseModel):
|
||||
text: str
|
||||
done: bool = False
|
||||
|
||||
class MotivationItem(BaseModel):
|
||||
text: str
|
||||
|
||||
class NotesUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
def _userdata_path(user: str) -> Path:
|
||||
"""Get the path to user data file."""
|
||||
# Sanitize username to prevent path traversal
|
||||
safe_user = re.sub(r'[^a-zA-Z0-9_-]', '', user)
|
||||
if not safe_user:
|
||||
raise HTTPException(status_code=400, detail="Invalid username")
|
||||
return DATA_DIR / f"userdata-{safe_user}.json"
|
||||
|
||||
def _load_userdata(user: str) -> dict:
|
||||
"""Load user data or return default structure."""
|
||||
default = {
|
||||
"notes": "",
|
||||
"todos": [],
|
||||
"motivation": [
|
||||
"Believe in yourself!",
|
||||
"Every day is a new opportunity.",
|
||||
"You've got this!",
|
||||
"Small steps lead to big changes.",
|
||||
"Stay focused, stay positive."
|
||||
]
|
||||
}
|
||||
path = _userdata_path(user)
|
||||
data = _load_json(path, default)
|
||||
# Ensure all keys exist
|
||||
for key in default:
|
||||
if key not in data:
|
||||
data[key] = default[key]
|
||||
return data
|
||||
|
||||
def _save_userdata(user: str, data: dict) -> None:
|
||||
"""Save user data to file."""
|
||||
path = _userdata_path(user)
|
||||
_save_json(path, data)
|
||||
|
||||
def _verify_key(key: str = Query(default="")):
|
||||
"""Verify API key for write operations."""
|
||||
if not GLANCE_HELPER_KEY:
|
||||
return True # No key configured, allow all
|
||||
if key != GLANCE_HELPER_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
return True
|
||||
|
||||
# ========== GET ALL USER DATA ==========
|
||||
@APP.get("/userdata/{user}")
|
||||
def get_userdata(user: str):
|
||||
"""Get all user data (notes, todos, motivation)."""
|
||||
data = _load_userdata(user)
|
||||
return Response(
|
||||
content=json.dumps(data, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
# ========== NOTES ==========
|
||||
@APP.get("/userdata/{user}/notes")
|
||||
def get_notes(user: str):
|
||||
"""Get user notes."""
|
||||
data = _load_userdata(user)
|
||||
return Response(
|
||||
content=json.dumps({"notes": data.get("notes", "")}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
@APP.post("/userdata/{user}/notes")
|
||||
def update_notes(user: str, body: NotesUpdate, key: str = Query(default="")):
|
||||
"""Update user notes."""
|
||||
_verify_key(key)
|
||||
data = _load_userdata(user)
|
||||
data["notes"] = body.content
|
||||
_save_userdata(user, data)
|
||||
return Response(
|
||||
content=json.dumps({"success": True, "notes": data["notes"]}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
# ========== TODOS ==========
|
||||
@APP.get("/userdata/{user}/todos")
|
||||
def get_todos(user: str):
|
||||
"""Get user todos."""
|
||||
data = _load_userdata(user)
|
||||
return Response(
|
||||
content=json.dumps({
|
||||
"todos": data.get("todos", []),
|
||||
"count": len(data.get("todos", []))
|
||||
}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
@APP.post("/userdata/{user}/todos")
|
||||
def add_todo(user: str, body: TodoItem, key: str = Query(default="")):
|
||||
"""Add a new todo item."""
|
||||
_verify_key(key)
|
||||
data = _load_userdata(user)
|
||||
|
||||
new_todo = {
|
||||
"id": str(uuid_lib.uuid4())[:8],
|
||||
"text": body.text.strip(),
|
||||
"done": body.done,
|
||||
"created": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
if not data.get("todos"):
|
||||
data["todos"] = []
|
||||
data["todos"].append(new_todo)
|
||||
_save_userdata(user, data)
|
||||
|
||||
return Response(
|
||||
content=json.dumps({"success": True, "todo": new_todo}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
@APP.put("/userdata/{user}/todos/{todo_id}")
|
||||
def update_todo(user: str, todo_id: str, body: TodoItem, key: str = Query(default="")):
|
||||
"""Update a todo item (toggle done, update text)."""
|
||||
_verify_key(key)
|
||||
data = _load_userdata(user)
|
||||
|
||||
for todo in data.get("todos", []):
|
||||
if todo.get("id") == todo_id:
|
||||
todo["text"] = body.text.strip()
|
||||
todo["done"] = body.done
|
||||
_save_userdata(user, data)
|
||||
return Response(
|
||||
content=json.dumps({"success": True, "todo": todo}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
|
||||
@APP.delete("/userdata/{user}/todos/{todo_id}")
|
||||
def delete_todo(user: str, todo_id: str, key: str = Query(default="")):
|
||||
"""Delete a todo item."""
|
||||
_verify_key(key)
|
||||
data = _load_userdata(user)
|
||||
|
||||
original_count = len(data.get("todos", []))
|
||||
data["todos"] = [t for t in data.get("todos", []) if t.get("id") != todo_id]
|
||||
|
||||
if len(data["todos"]) == original_count:
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
|
||||
_save_userdata(user, data)
|
||||
return Response(
|
||||
content=json.dumps({"success": True, "deleted": todo_id}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
# ========== MOTIVATION ==========
|
||||
@APP.get("/userdata/{user}/motivation")
|
||||
def get_motivation(user: str):
|
||||
"""Get all motivation quotes."""
|
||||
data = _load_userdata(user)
|
||||
quotes = data.get("motivation", [])
|
||||
return Response(
|
||||
content=json.dumps({
|
||||
"quotes": quotes,
|
||||
"count": len(quotes)
|
||||
}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
@APP.get("/userdata/{user}/motivation/random")
|
||||
def get_random_motivation(user: str):
|
||||
"""Get a random motivation quote."""
|
||||
data = _load_userdata(user)
|
||||
quotes = data.get("motivation", [])
|
||||
|
||||
if not quotes:
|
||||
return Response(
|
||||
content=json.dumps({
|
||||
"quote": "Add some motivation quotes!",
|
||||
"index": -1,
|
||||
"total": 0
|
||||
}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
idx = random.randint(0, len(quotes) - 1)
|
||||
return Response(
|
||||
content=json.dumps({
|
||||
"quote": quotes[idx],
|
||||
"index": idx,
|
||||
"total": len(quotes)
|
||||
}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
@APP.post("/userdata/{user}/motivation")
|
||||
def add_motivation(user: str, body: MotivationItem, key: str = Query(default="")):
|
||||
"""Add a new motivation quote."""
|
||||
_verify_key(key)
|
||||
data = _load_userdata(user)
|
||||
|
||||
if not data.get("motivation"):
|
||||
data["motivation"] = []
|
||||
|
||||
quote_text = body.text.strip()
|
||||
if quote_text and quote_text not in data["motivation"]:
|
||||
data["motivation"].append(quote_text)
|
||||
_save_userdata(user, data)
|
||||
|
||||
return Response(
|
||||
content=json.dumps({
|
||||
"success": True,
|
||||
"quotes": data["motivation"],
|
||||
"count": len(data["motivation"])
|
||||
}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
@APP.delete("/userdata/{user}/motivation/{index}")
|
||||
def delete_motivation(user: str, index: int, key: str = Query(default="")):
|
||||
"""Delete a motivation quote by index."""
|
||||
_verify_key(key)
|
||||
data = _load_userdata(user)
|
||||
quotes = data.get("motivation", [])
|
||||
|
||||
if index < 0 or index >= len(quotes):
|
||||
raise HTTPException(status_code=404, detail="Quote not found")
|
||||
|
||||
deleted = quotes.pop(index)
|
||||
_save_userdata(user, data)
|
||||
|
||||
return Response(
|
||||
content=json.dumps({
|
||||
"success": True,
|
||||
"deleted": deleted,
|
||||
"quotes": quotes,
|
||||
"count": len(quotes)
|
||||
}, ensure_ascii=False),
|
||||
media_type="application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -1022,7 +1279,7 @@ spec:
|
||||
apt-get update;
|
||||
apt-get install -y --no-install-recommends curl ca-certificates iputils-ping dnsutils tzdata net-tools;
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus-client icalendar python-dateutil;
|
||||
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus-client icalendar python-dateutil pydantic;
|
||||
python -c "import uvicorn; uvicorn.run('app:APP', host='0.0.0.0', port=8000)"
|
||||
command:
|
||||
- /bin/sh
|
||||
|
||||
@@ -763,8 +763,171 @@ data:
|
||||
# Calendar Widget
|
||||
- type: calendar
|
||||
first-day-of-week: monday
|
||||
# Tasks (persistent, file-based)
|
||||
- type: to-do
|
||||
# To-Do List Widget (custom-api with persistent storage)
|
||||
- type: custom-api
|
||||
title: To-Do
|
||||
cache: 30s
|
||||
url: http://glance-helper.glance-system.svc.cluster.local:8000/userdata/kisfenyo/todos
|
||||
options:
|
||||
api_base: https://glance-helper.dooplex.hu
|
||||
user: kisfenyo
|
||||
api_key: oplQqnLnJK2vErRVYJpvVUcSDBOSbCHZSbsYY2bwSifgTMfT
|
||||
template: |
|
||||
{{ $apiBase := .Options.StringOr "api_base" "https://glance-helper.dooplex.hu" }}
|
||||
{{ $user := .Options.StringOr "user" "kisfenyo" }}
|
||||
{{ $apiKey := .Options.StringOr "api_key" "" }}
|
||||
{{ $todos := .JSON.Array "todos" }}
|
||||
{{ $widgetId := "todo-kisfenyo" }}
|
||||
|
||||
<style>
|
||||
.todo-widget { display: flex; flex-direction: column; gap: 8px; }
|
||||
.todo-add { display: flex; gap: 8px; align-items: center; }
|
||||
.todo-add-btn {
|
||||
background: none; border: none; color: inherit; opacity: 0.6;
|
||||
cursor: pointer; font-size: 16px; padding: 4px 8px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.todo-add-btn:hover { opacity: 1; }
|
||||
.todo-add-input {
|
||||
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px; padding: 8px 10px; color: inherit; font-size: 13px;
|
||||
outline: none; transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.todo-add-input:focus {
|
||||
background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.25);
|
||||
}
|
||||
.todo-add-input::placeholder { opacity: 0.5; }
|
||||
.todo-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.todo-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
border-radius: 8px; background: rgba(255,255,255,0.04);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.todo-item:hover { background: rgba(255,255,255,0.08); }
|
||||
.todo-checkbox {
|
||||
width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.4);
|
||||
border-radius: 4px; cursor: pointer; display: flex; align-items: center;
|
||||
justify-content: center; flex-shrink: 0; transition: all 0.15s;
|
||||
background: transparent;
|
||||
}
|
||||
.todo-checkbox:hover { border-color: rgba(255,255,255,0.7); }
|
||||
.todo-checkbox.done {
|
||||
background: rgba(34, 197, 94, 0.8); border-color: rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
.todo-checkbox.done::after { content: "✓"; font-size: 11px; color: #fff; }
|
||||
.todo-text { flex: 1; font-size: 13px; opacity: 0.95; line-height: 1.3; }
|
||||
.todo-text.done { text-decoration: line-through; opacity: 0.5; }
|
||||
.todo-delete {
|
||||
opacity: 0; background: none; border: none; color: inherit;
|
||||
cursor: pointer; padding: 4px 6px; font-size: 14px;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.todo-item:hover .todo-delete { opacity: 0.5; }
|
||||
.todo-delete:hover { opacity: 1 !important; color: #ef4444; }
|
||||
.todo-empty { opacity: 0.5; font-size: 13px; padding: 8px 0; text-align: center; }
|
||||
.todo-loading { opacity: 0.5; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
<div class="todo-widget" id="{{ $widgetId }}">
|
||||
<div class="todo-add">
|
||||
<span class="todo-add-btn" onclick="todoAddFocus_{{ $widgetId }}()">+</span>
|
||||
<input type="text" class="todo-add-input" id="{{ $widgetId }}-input"
|
||||
placeholder="Add a task"
|
||||
onkeydown="if(event.key==='Enter')todoAdd_{{ $widgetId }}(this.value)">
|
||||
</div>
|
||||
<div class="todo-list" id="{{ $widgetId }}-list">
|
||||
{{ if eq (len $todos) 0 }}
|
||||
<div class="todo-empty">No tasks yet</div>
|
||||
{{ else }}
|
||||
{{ range $todos }}
|
||||
{{ $id := .String "id" }}
|
||||
{{ $text := .String "text" }}
|
||||
{{ $done := .Bool "done" }}
|
||||
<div class="todo-item" data-id="{{ $id }}" data-text="{{ $text }}" data-done="{{ $done }}">
|
||||
<div class="todo-checkbox {{ if $done }}done{{ end }}"
|
||||
onclick="todoToggle_{{ $widgetId }}(this.parentElement)"></div>
|
||||
<span class="todo-text {{ if $done }}done{{ end }}">{{ $text }}</span>
|
||||
<button class="todo-delete" onclick="todoDelete_{{ $widgetId }}('{{ $id }}')" title="Delete">🗑</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const API_BASE = '{{ $apiBase }}';
|
||||
const USER = '{{ $user }}';
|
||||
const API_KEY = '{{ $apiKey }}';
|
||||
const WIDGET_ID = '{{ $widgetId }}';
|
||||
|
||||
function getUrl(path) {
|
||||
return API_BASE + path + (API_KEY ? '?key=' + API_KEY : '');
|
||||
}
|
||||
|
||||
window['todoAddFocus_' + WIDGET_ID] = function() {
|
||||
document.getElementById(WIDGET_ID + '-input').focus();
|
||||
};
|
||||
|
||||
window['todoAdd_' + WIDGET_ID] = async function(text) {
|
||||
text = text.trim();
|
||||
if (!text) return;
|
||||
|
||||
const input = document.getElementById(WIDGET_ID + '-input');
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch(getUrl('/userdata/' + USER + '/todos'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text, done: false })
|
||||
});
|
||||
if (resp.ok) {
|
||||
input.value = '';
|
||||
location.reload();
|
||||
} else {
|
||||
console.error('Failed to add todo:', await resp.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error adding todo:', e);
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window['todoToggle_' + WIDGET_ID] = async function(itemEl) {
|
||||
const id = itemEl.dataset.id;
|
||||
const text = itemEl.dataset.text;
|
||||
const newDone = itemEl.dataset.done !== 'true';
|
||||
try {
|
||||
const resp = await fetch(getUrl('/userdata/' + USER + '/todos/' + id), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text, done: newDone })
|
||||
});
|
||||
if (resp.ok) {
|
||||
location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error toggling todo:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window['todoDelete_' + WIDGET_ID] = async function(id) {
|
||||
try {
|
||||
const resp = await fetch(getUrl('/userdata/' + USER + '/todos/' + id), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (resp.ok) {
|
||||
location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error deleting todo:', e);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
# Outline Notes iframe
|
||||
- type: iframe
|
||||
source: https://outline.dooplex.hu/collection/dooplex-server-iTAZn04AaR/recent
|
||||
@@ -848,6 +1011,259 @@ data:
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
# Motivation Quote Widget
|
||||
- type: custom-api
|
||||
title: Motivation
|
||||
cache: 1m
|
||||
url: http://glance-helper.glance-system.svc.cluster.local:8000/userdata/kisfenyo/motivation/random
|
||||
options:
|
||||
api_base: https://glance-helper.dooplex.hu
|
||||
user: kisfenyo
|
||||
api_key: oplQqnLnJK2vErRVYJpvVUcSDBOSbCHZSbsYY2bwSifgTMfT
|
||||
template: |
|
||||
{{ $apiBase := .Options.StringOr "api_base" "https://glance-helper.dooplex.hu" }}
|
||||
{{ $user := .Options.StringOr "user" "kisfenyo" }}
|
||||
{{ $apiKey := .Options.StringOr "api_key" "" }}
|
||||
{{ $quote := .JSON.String "quote" }}
|
||||
{{ $total := .JSON.Int "total" }}
|
||||
{{ $widgetId := "motiv-kisfenyo" }}
|
||||
|
||||
<style>
|
||||
.motiv-widget { position: relative; }
|
||||
.motiv-quote {
|
||||
font-size: 14px; line-height: 1.5; opacity: 0.9;
|
||||
padding: 12px 14px; background: rgba(255,255,255,0.04);
|
||||
border-radius: 10px; border-left: 3px solid rgba(96, 165, 250, 0.6);
|
||||
font-style: italic;
|
||||
}
|
||||
.motiv-meta {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-top: 8px; font-size: 11px; opacity: 0.5;
|
||||
}
|
||||
.motiv-gear {
|
||||
cursor: pointer; padding: 2px 6px; border-radius: 4px;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.motiv-gear:hover { opacity: 1; background: rgba(255,255,255,0.1); }
|
||||
|
||||
/* Management Modal */
|
||||
.motiv-modal-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6); z-index: 99999;
|
||||
align-items: flex-start; justify-content: center;
|
||||
padding-top: 8vh; backdrop-filter: blur(4px);
|
||||
}
|
||||
.motiv-modal-overlay.active { display: flex; }
|
||||
.motiv-modal {
|
||||
width: min(500px, 90vw); max-height: 80vh;
|
||||
background: rgba(20, 25, 35, 0.98); border-radius: 14px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
}
|
||||
.motiv-modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.motiv-modal-title { font-weight: 600; font-size: 15px; }
|
||||
.motiv-modal-close {
|
||||
background: none; border: none; color: inherit; cursor: pointer;
|
||||
font-size: 18px; opacity: 0.6; padding: 4px 8px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.motiv-modal-close:hover { opacity: 1; }
|
||||
.motiv-modal-body {
|
||||
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.motiv-modal-add {
|
||||
display: flex; gap: 8px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.motiv-modal-input {
|
||||
flex: 1; background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px; padding: 10px 12px;
|
||||
color: inherit; font-size: 13px; outline: none;
|
||||
}
|
||||
.motiv-modal-input:focus {
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
}
|
||||
.motiv-modal-btn {
|
||||
background: rgba(96, 165, 250, 0.3); border: none;
|
||||
color: inherit; padding: 10px 16px; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.motiv-modal-btn:hover { background: rgba(96, 165, 250, 0.5); }
|
||||
.motiv-modal-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.motiv-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
|
||||
.motiv-list-title {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
opacity: 0.5; margin-bottom: 4px;
|
||||
}
|
||||
.motiv-item {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; background: rgba(255,255,255,0.04);
|
||||
border-radius: 8px; transition: background 0.15s;
|
||||
}
|
||||
.motiv-item:hover { background: rgba(255,255,255,0.08); }
|
||||
.motiv-item-text {
|
||||
flex: 1; font-size: 13px; line-height: 1.4; opacity: 0.9;
|
||||
}
|
||||
.motiv-item-delete {
|
||||
background: none; border: none; color: inherit;
|
||||
cursor: pointer; opacity: 0.4; padding: 2px 6px;
|
||||
font-size: 13px; transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.motiv-item-delete:hover { opacity: 1; color: #ef4444; }
|
||||
.motiv-empty {
|
||||
text-align: center; opacity: 0.5; padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.motiv-loading { text-align: center; padding: 20px; opacity: 0.6; }
|
||||
</style>
|
||||
|
||||
<div class="motiv-widget" id="{{ $widgetId }}">
|
||||
<div class="motiv-quote">{{ $quote }}</div>
|
||||
<div class="motiv-meta">
|
||||
<span>{{ $total }} quotes</span>
|
||||
<span class="motiv-gear" onclick="motivOpenModal_{{ $widgetId }}()" title="Manage quotes">⚙️ Manage</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Management Modal (appended to body) -->
|
||||
<div class="motiv-modal-overlay" id="{{ $widgetId }}-modal">
|
||||
<div class="motiv-modal">
|
||||
<div class="motiv-modal-header">
|
||||
<span class="motiv-modal-title">Manage Motivation Quotes</span>
|
||||
<button class="motiv-modal-close" onclick="motivCloseModal_{{ $widgetId }}()">×</button>
|
||||
</div>
|
||||
<div class="motiv-modal-body">
|
||||
<div class="motiv-modal-add">
|
||||
<input type="text" class="motiv-modal-input" id="{{ $widgetId }}-new"
|
||||
placeholder="Add a new motivational quote..."
|
||||
onkeydown="if(event.key==='Enter')motivAdd_{{ $widgetId }}()">
|
||||
<button class="motiv-modal-btn" onclick="motivAdd_{{ $widgetId }}()">Add</button>
|
||||
</div>
|
||||
<div class="motiv-list-title">Your Quotes</div>
|
||||
<div class="motiv-list" id="{{ $widgetId }}-list">
|
||||
<div class="motiv-loading">Loading quotes...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const API_BASE = '{{ $apiBase }}';
|
||||
const USER = '{{ $user }}';
|
||||
const API_KEY = '{{ $apiKey }}';
|
||||
const WIDGET_ID = '{{ $widgetId }}';
|
||||
|
||||
let quotesCache = [];
|
||||
|
||||
function getUrl(path) {
|
||||
return API_BASE + path + (API_KEY ? '?key=' + API_KEY : '');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function loadQuotes() {
|
||||
const listEl = document.getElementById(WIDGET_ID + '-list');
|
||||
try {
|
||||
const resp = await fetch(getUrl('/userdata/' + USER + '/motivation').replace('?key=', ''));
|
||||
const data = await resp.json();
|
||||
quotesCache = data.quotes || [];
|
||||
renderQuotes();
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="motiv-empty">Error loading quotes</div>';
|
||||
console.error('Error loading quotes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuotes() {
|
||||
const listEl = document.getElementById(WIDGET_ID + '-list');
|
||||
if (quotesCache.length === 0) {
|
||||
listEl.innerHTML = '<div class="motiv-empty">No quotes yet. Add some above!</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = quotesCache.map((q, i) =>
|
||||
'<div class="motiv-item">' +
|
||||
'<span class="motiv-item-text">' + escapeHtml(q) + '</span>' +
|
||||
'<button class="motiv-item-delete" onclick="motivDelete_' + WIDGET_ID + '(' + i + ')" title="Delete">🗑</button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
window['motivOpenModal_' + WIDGET_ID] = function() {
|
||||
const modal = document.getElementById(WIDGET_ID + '-modal');
|
||||
modal.classList.add('active');
|
||||
loadQuotes();
|
||||
// Close on overlay click
|
||||
modal.onclick = function(e) {
|
||||
if (e.target === modal) window['motivCloseModal_' + WIDGET_ID]();
|
||||
};
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', function handler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
window['motivCloseModal_' + WIDGET_ID]();
|
||||
document.removeEventListener('keydown', handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window['motivCloseModal_' + WIDGET_ID] = function() {
|
||||
document.getElementById(WIDGET_ID + '-modal').classList.remove('active');
|
||||
};
|
||||
|
||||
window['motivAdd_' + WIDGET_ID] = async function() {
|
||||
const input = document.getElementById(WIDGET_ID + '-new');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
input.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(getUrl('/userdata/' + USER + '/motivation'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text })
|
||||
});
|
||||
if (resp.ok) {
|
||||
input.value = '';
|
||||
const data = await resp.json();
|
||||
quotesCache = data.quotes || [];
|
||||
renderQuotes();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error adding quote:', e);
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window['motivDelete_' + WIDGET_ID] = async function(index) {
|
||||
try {
|
||||
const resp = await fetch(getUrl('/userdata/' + USER + '/motivation/' + index), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
quotesCache = data.quotes || [];
|
||||
renderQuotes();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error deleting quote:', e);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
# Calendar Events Widget (Családi only)
|
||||
- type: custom-api
|
||||
|
||||
Reference in New Issue
Block a user