CI / test-and-package (push) Successful in 40s
Add hard-to-guess public URLs (/public/<token>) that show a read-only meal status dashboard for each household. Only people with the link can see the data — no login required. - models.py: public_token + public_enabled columns on households, get_household_by_public_token(), generate_public_token(), set_public_enabled() - meals.py: /public/<token> route, /admin/public-link for generate/disable/enable/regenerate - templates/public.html: clean read-only public dashboard - templates/public-not-found.html: 404 for invalid/disabled links - templates/dashboard.html: admin UI with copy/regenerate/disable - static/style.css: public page and URL box styles - i18n.py: 18 new translation keys (en/fr/de)
189 lines
10 KiB
HTML
189 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ t('Dashboard — Meal Tracker') }}{% endblock %}
|
|
{% block content %}
|
|
|
|
<div class="dashboard-header">
|
|
<h2>
|
|
{% if history_mode %}{{ t('History — {date}', date=viewing_date.isoformat()) }}{% else %}{{ t("Today's Meals") }}{% endif %}
|
|
</h2>
|
|
<div class="date-nav">
|
|
<a class="btn btn-sm" href="?date={{ prev_date.isoformat() }}">{{ t('◀ Prev') }}</a>
|
|
<span class="date-display">{{ viewing_date.isoformat() }}</span>
|
|
<a class="btn btn-sm" href="?date={{ next_date.isoformat() }}">{{ t('Next ▶') }}</a>
|
|
{% if viewing_date != today %}
|
|
<a class="btn btn-sm" href="{{ url_for('meals.dashboard') }}">{{ t('Today') }}</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if not dashboard.users %}
|
|
<p class="empty-state">{{ t('No registered users yet. Ask household members to register!') }}</p>
|
|
{% else %}
|
|
<div class="table-wrapper">
|
|
<table class="meal-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('User') }}</th>
|
|
{% for period in dashboard.periods %}
|
|
<th class="meal-col">{{ t(period.meal_type|capitalize) }}</th>
|
|
{% endfor %}
|
|
{% if is_admin %}
|
|
<th class="action-col"></th>
|
|
{% endif %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for user in dashboard.users %}
|
|
{% set is_me = user.id == current_user_id %}
|
|
<tr>
|
|
<td class="user-cell">
|
|
{{ user.username }}
|
|
{% if user.is_admin %}<span class="admin-tag">{{ t('admin') }}</span>{% endif %}
|
|
{% if is_me %}<span class="you-tag">{{ t('you') }}</span>{% endif %}
|
|
</td>
|
|
{% for period in dashboard.periods %}
|
|
{% set resp = user.responses[period.meal_type] %}
|
|
<td class="response-cell status-{{ resp.status }}{% if resp.changed_at %} changed-mind{% endif %}">
|
|
{% if is_me and viewing_date >= today %}
|
|
<form method="POST" action="{{ url_for('meals.respond') }}" class="respond-form">
|
|
<input type="hidden" name="date" value="{{ viewing_date.isoformat() }}">
|
|
<input type="hidden" name="meal_type" value="{{ period.meal_type }}">
|
|
<div class="btn-row">
|
|
<button name="status" value="yes"
|
|
class="chip chip-yes {% if resp.status == 'yes' %}active{% endif %}"
|
|
>{{ t('Home') }}</button>
|
|
<button name="status" value="no"
|
|
class="chip chip-no {% if resp.status == 'no' %}active{% endif %}"
|
|
>{{ t('Out') }}</button>
|
|
</div>
|
|
</form>
|
|
{% if resp.changed_at %}
|
|
<div class="changed-badge" title="{{ t('Changed mind at {time}', time=resp.changed_at) }}">↻ {{ resp.changed_at[11:16] }}</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="badge badge-{{ resp.status }}">{{ t('Home') if resp.status == 'yes' else (t('Out') if resp.status == 'no' else t('—')) }}</span>
|
|
{% endif %}
|
|
</td>
|
|
{% endfor %}
|
|
{% if is_admin and not is_me and not user.is_admin %}
|
|
<td class="action-cell">
|
|
<form method="POST" action="{{ url_for('meals.admin_remove_user', user_id=user.id) }}"
|
|
onsubmit="return confirm(this.dataset.confirmMsg);"
|
|
data-confirm-msg="{{ t('Remove {name} from this household?', name=user.username) }}">
|
|
<button class="btn btn-sm btn-danger">{{ t('Remove') }}</button>
|
|
</form>
|
|
</td>
|
|
{% elif is_admin %}
|
|
<td class="action-cell"></td>
|
|
{% endif %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="manage-section">
|
|
<details class="manage-details">
|
|
<summary>{{ t('⚙ Account Settings') }}</summary>
|
|
<div class="manage-forms">
|
|
<form method="POST" action="{{ url_for('meals.delete_account') }}"
|
|
onsubmit="return confirm(this.dataset.confirmMsg);"
|
|
data-confirm-msg="{{ t('Delete YOUR account? This cannot be undone.') }}"
|
|
class="manage-form">
|
|
<h4>{{ t('Delete My Account') }}</h4>
|
|
<input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required>
|
|
<button class="btn btn-danger">{{ t('Delete My Account') }}</button>
|
|
</form>
|
|
|
|
<form method="POST" action="{{ url_for('meals.ntfy_settings') }}" class="manage-form">
|
|
<h4>🔔 {{ t('Ntfy Notifications') }}</h4>
|
|
<p class="hint">{{ t('Get meal reminders on your phone via ntfy.') }}</p>
|
|
<label for="ntfy_url">{{ t('Ntfy Topic URL') }}</label>
|
|
<input type="text" id="ntfy_url" name="ntfy_url"
|
|
placeholder="https://ntfy.sh/your-topic"
|
|
value="{{ ntfy.ntfy_url or '' }}">
|
|
<label for="ntfy_token">{{ t('Access Token') }} <span class="hint">{{ t('(optional)') }}</span></label>
|
|
<input type="password" id="ntfy_token" name="ntfy_token"
|
|
placeholder="tk_..."
|
|
value="{{ ntfy.ntfy_token or '' }}">
|
|
<div class="btn-row" style="margin-top: 8px;">
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('Save') }}</button>
|
|
<button type="submit" class="btn btn-sm" formaction="{{ url_for('meals.ntfy_test') }}">{{ t('Send Test') }}</button>
|
|
</div>
|
|
</form>
|
|
|
|
{% if is_admin %}
|
|
<form method="POST" action="{{ url_for('meals.admin_public_link') }}" class="manage-form">
|
|
<h4>🔗 {{ t('Public Sharing Link') }}</h4>
|
|
<p class="hint">{{ t('Share a read-only view of your household\'s meal status. The link is hard to guess — only people with it can see the data.') }}</p>
|
|
{% if household.public_token and household.public_enabled %}
|
|
<div class="public-url-box">
|
|
<code>{{ request.host_url }}public/{{ household.public_token }}</code>
|
|
<button type="button" class="btn btn-sm" onclick="copyPublicUrl(this)">{{ t('Copy') }}</button>
|
|
</div>
|
|
<div class="btn-row" style="margin-top: 8px;">
|
|
<button type="submit" name="action" value="generate" class="btn btn-sm">{{ t('Regenerate') }}</button>
|
|
<button type="submit" name="action" value="disable" class="btn btn-sm btn-danger">{{ t('Disable') }}</button>
|
|
</div>
|
|
{% elif household.public_token and not household.public_enabled %}
|
|
<p class="hint">{{ t('Public link is currently disabled.') }}</p>
|
|
<button type="submit" name="action" value="enable" class="btn btn-sm">{{ t('Enable') }}</button>
|
|
{% else %}
|
|
<p class="hint">{{ t('No public link yet. Generate one to share.') }}</p>
|
|
<button type="submit" name="action" value="generate" class="btn btn-sm">{{ t('Generate Link') }}</button>
|
|
{% endif %}
|
|
</form>
|
|
|
|
<form method="POST" action="{{ url_for('meals.admin_set_timezone') }}" class="manage-form">
|
|
<h4>🕐 {{ t('Household Timezone') }}</h4>
|
|
<p class="hint">{{ t('Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.') }}</p>
|
|
<select name="timezone" class="select-input">
|
|
{% set tz = household.timezone or 'UTC' %}
|
|
{% for zone in ['UTC', 'Europe/Zurich', 'Europe/Paris', 'Europe/Berlin', 'Europe/London',
|
|
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
|
'America/Toronto', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires',
|
|
'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Kolkata', 'Asia/Dubai',
|
|
'Australia/Sydney', 'Pacific/Auckland', 'Africa/Cairo', 'Africa/Johannesburg'] %}
|
|
<option value="{{ zone }}" {% if zone == tz %}selected{% endif %}>{{ zone }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button type="submit" class="btn btn-primary btn-sm" style="margin-top: 8px;">{{ t('Save') }}</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
{% if is_admin %}
|
|
<form method="POST" action="{{ url_for('meals.admin_delete_household') }}"
|
|
onsubmit="return confirm(this.dataset.confirmMsg);"
|
|
data-confirm-msg="{{ t('Delete the ENTIRE household and ALL members? This cannot be undone.') }}"
|
|
class="manage-form">
|
|
<h4>{{ t('Delete Household') }}</h4>
|
|
<p class="hint">{{ t('This will remove all users and all data in this household.') }}</p>
|
|
<input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required>
|
|
<button class="btn btn-danger">{{ t('Delete Household') }}</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<script>
|
|
function copyPublicUrl(btn) {
|
|
var code = btn.parentElement.querySelector('code');
|
|
if (!code) return;
|
|
navigator.clipboard.writeText(code.textContent).then(function() {
|
|
var orig = btn.textContent;
|
|
btn.textContent = 'Copied!';
|
|
btn.disabled = true;
|
|
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 2000);
|
|
}).catch(function() {
|
|
var range = document.createRange();
|
|
range.selectNode(code);
|
|
window.getSelection().removeAllRanges();
|
|
window.getSelection().addRange(range);
|
|
});
|
|
}
|
|
</script>
|
|
|
|
{% endblock %}
|