Compare commits
2 Commits
v1.1.3
...
24ab43cceb
| Author | SHA1 | Date | |
|---|---|---|---|
| 24ab43cceb | |||
| 898ad38303 |
+2
-1
@@ -1,4 +1,5 @@
|
||||
tailwindcss
|
||||
static/output.css
|
||||
.env
|
||||
__pycache__
|
||||
__pycache__
|
||||
echolog.db
|
||||
@@ -14,62 +14,42 @@ A Flask-based web application for personal homelab journaling. Track your daily
|
||||
- **PWA Support**: Progressive Web App making this installable on mobile devices
|
||||
- **Modern UI**: Beautiful gradient design with Tailwind CSS and responsive layout
|
||||
- **Optional Authentication**: Enable login protection with environment variables
|
||||
- **MySQL Backend**: Robust data persistence with MySQL/MariaDB
|
||||
- **Multiple Database Support**: Use MySQL/MariaDB or SQLite for data persistence
|
||||
- **Docker Ready**: Easy deployment with Docker and Docker Compose
|
||||
- **Kubernetes Support**: Includes Kubernetes deployment manifest
|
||||
|
||||
## Quick Start with Docker
|
||||
|
||||
### Docker Run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name echolog \
|
||||
-p 5000:5000 \
|
||||
-e MYSQL_HOST=10.10.2.27 \
|
||||
-e MYSQL_USER=echolog \
|
||||
-e MYSQL_PASSWORD=your_password \
|
||||
-e MYSQL_DATABASE=echolog \
|
||||
-e SECRET_KEY=your_secret_key \
|
||||
-e TZ=Europe/London \
|
||||
-e LOGIN_ENABLED=true \
|
||||
-e LOGIN_USERNAME=admin \
|
||||
-e LOGIN_PASSWORD=your_password \
|
||||
cr.jdbnet.co.uk/public/echolog:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
echolog:
|
||||
image: cr.jdbnet.co.uk/public/echolog:latest
|
||||
container_name: echolog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- MYSQL_HOST=10.10.2.27
|
||||
- MYSQL_USER=echolog
|
||||
- MYSQL_PASSWORD=your_password
|
||||
- MYSQL_DATABASE=echolog
|
||||
- SECRET_KEY=your_secret_key
|
||||
- TZ=Europe/London
|
||||
- LOGIN_ENABLED=true
|
||||
- LOGIN_USERNAME=admin
|
||||
- LOGIN_PASSWORD=your_password
|
||||
```
|
||||
For deployment examples, see the [examples folder](examples/).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Database Selection
|
||||
|
||||
EchoLog supports both MySQL/MariaDB and SQLite:
|
||||
|
||||
- SQLite (default for quick start)
|
||||
- MySQL/MariaDB
|
||||
|
||||
See the [examples folder](examples/) for docker-compose files for both database types.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Database Configuration
|
||||
|
||||
- `DB_TYPE`: Database type - `mysql` (default) or `sqlite`
|
||||
|
||||
**MySQL/MariaDB options:**
|
||||
- `MYSQL_HOST`: MySQL/MariaDB host (default: localhost)
|
||||
- `MYSQL_USER`: Database user (default: root)
|
||||
- `MYSQL_PASSWORD`: Database password (default: empty)
|
||||
- `MYSQL_DATABASE`: Database name (default: echolog)
|
||||
|
||||
**SQLite options:**
|
||||
- `SQLITE_DB`: Path to SQLite database file (default: echolog.db)
|
||||
|
||||
#### Application Configuration
|
||||
|
||||
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
|
||||
- `TZ`: Timezone for date handling (default: UTC)
|
||||
- `LOGIN_ENABLED`: Enable login protection (default: false)
|
||||
@@ -78,6 +58,8 @@ services:
|
||||
|
||||
### Database Setup
|
||||
|
||||
#### MySQL/MariaDB
|
||||
|
||||
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate permissions:
|
||||
|
||||
```sql
|
||||
@@ -87,6 +69,10 @@ GRANT ALL PRIVILEGES ON echolog.* TO 'echolog'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
#### SQLite
|
||||
|
||||
SQLite requires no setup - the database file is created automatically on first run. Ensure the directory where the database file is stored is writable by the application.
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating Entries
|
||||
@@ -112,75 +98,6 @@ FLUSH PRIVILEGES;
|
||||
|
||||
The streak counter automatically tracks consecutive days with journal entries. The streak includes today or yesterday to start, and continues as long as you have entries on consecutive days.
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
|
||||
|
||||
**Example Kubernetes deployment:**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: echolog
|
||||
namespace: echolog
|
||||
spec:
|
||||
replicas: 1
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: echolog
|
||||
image: cr.jdbnet.co.uk/public/echolog:latest
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
env:
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: echolog-secrets
|
||||
key: secret-key
|
||||
- name: MYSQL_HOST
|
||||
value: "mysql-service"
|
||||
- name: MYSQL_USER
|
||||
value: "echolog"
|
||||
- name: MYSQL_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: echolog-secrets
|
||||
key: mysql-password
|
||||
- name: MYSQL_DATABASE
|
||||
value: "echolog"
|
||||
- name: TZ
|
||||
value: "Europe/London"
|
||||
- name: LOGIN_ENABLED
|
||||
value: "true"
|
||||
- name: LOGIN_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: echolog-secrets
|
||||
key: username
|
||||
- name: LOGIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: echolog-secrets
|
||||
key: password
|
||||
```
|
||||
|
||||
## Progressive Web App (PWA)
|
||||
|
||||
EchoLog can be installed as a Progressive Web App on supported devices:
|
||||
|
||||
- **Mobile**: Add to home screen from browser menu
|
||||
- **Desktop**: Install from browser address bar
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **ENABLE LOGIN** in production by setting `LOGIN_ENABLED=true`
|
||||
- **CHANGE THE DEFAULT CREDENTIALS** if using authentication
|
||||
- **CHANGE THE SECRET_KEY** in production - use a strong random string (e.g., `openssl rand -hex 32`)
|
||||
- Use strong passwords for database access
|
||||
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
import pytz
|
||||
import logging
|
||||
import sqlite3
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
|
||||
from datetime import datetime, date as datedate, datetime as dt
|
||||
import mysql.connector
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
@@ -18,28 +18,58 @@ tz = pytz.timezone(TIMEZONE)
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'defaultsecret')
|
||||
app.config['LOGIN_ENABLED'] = os.getenv('LOGIN_ENABLED', 'false').lower() == 'true'
|
||||
|
||||
mysql_config = {
|
||||
'user': os.getenv('MYSQL_USER', 'root'),
|
||||
'password': os.getenv('MYSQL_PASSWORD', ''),
|
||||
'host': os.getenv('MYSQL_HOST', 'localhost'),
|
||||
'database': os.getenv('MYSQL_DATABASE', 'echolog')
|
||||
}
|
||||
# Database configuration
|
||||
DB_TYPE = os.getenv('DB_TYPE', 'mysql').lower()
|
||||
|
||||
if DB_TYPE == 'mysql':
|
||||
import mysql.connector
|
||||
mysql_config = {
|
||||
'user': os.getenv('MYSQL_USER', 'root'),
|
||||
'password': os.getenv('MYSQL_PASSWORD', ''),
|
||||
'host': os.getenv('MYSQL_HOST', 'localhost'),
|
||||
'database': os.getenv('MYSQL_DATABASE', 'echolog')
|
||||
}
|
||||
elif DB_TYPE == 'sqlite':
|
||||
SQLITE_DB = os.getenv('SQLITE_DB', 'echolog.db')
|
||||
else:
|
||||
raise ValueError(f"Unsupported DB_TYPE: {DB_TYPE}. Use 'sqlite' or 'mysql'")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s')
|
||||
|
||||
def get_db_connection():
|
||||
return mysql.connector.connect(**mysql_config)
|
||||
if DB_TYPE == 'mysql':
|
||||
return mysql.connector.connect(**mysql_config)
|
||||
elif DB_TYPE == 'sqlite':
|
||||
conn = sqlite3.connect(SQLITE_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def dict_from_row(row):
|
||||
"""Convert database row to dictionary"""
|
||||
if DB_TYPE == 'mysql':
|
||||
return row
|
||||
elif DB_TYPE == 'sqlite':
|
||||
return dict(row) if row else None
|
||||
|
||||
def init_db():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS journal_entry (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
if DB_TYPE == 'mysql':
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS journal_entry (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
elif DB_TYPE == 'sqlite':
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS journal_entry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -48,7 +78,10 @@ def calculate_streak():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT date FROM journal_entry ORDER BY date DESC")
|
||||
dates = [row[0] for row in cursor.fetchall()]
|
||||
if DB_TYPE == 'mysql':
|
||||
dates = [row[0] for row in cursor.fetchall()]
|
||||
elif DB_TYPE == 'sqlite':
|
||||
dates = [row[0] for row in cursor.fetchall()]
|
||||
cursor.close()
|
||||
conn.close()
|
||||
if not dates:
|
||||
@@ -84,14 +117,25 @@ def index():
|
||||
per_page = 7
|
||||
offset = (page - 1) * per_page
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT SQL_CALC_FOUND_ROWS * FROM journal_entry ORDER BY date DESC LIMIT %s OFFSET %s", (per_page, offset))
|
||||
entries = cursor.fetchall()
|
||||
cursor.execute("SELECT FOUND_ROWS() as total")
|
||||
total = cursor.fetchone()['total']
|
||||
cursor = conn.cursor()
|
||||
|
||||
if DB_TYPE == 'mysql':
|
||||
cursor.execute("SELECT SQL_CALC_FOUND_ROWS * FROM journal_entry ORDER BY date DESC LIMIT %s OFFSET %s", (per_page, offset))
|
||||
entries = cursor.fetchall()
|
||||
cursor.execute("SELECT FOUND_ROWS() as total")
|
||||
total = cursor.fetchone()['total']
|
||||
elif DB_TYPE == 'sqlite':
|
||||
cursor.execute("SELECT COUNT(*) as total FROM journal_entry")
|
||||
total = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT * FROM journal_entry ORDER BY date DESC LIMIT ? OFFSET ?", (per_page, offset))
|
||||
entries = [dict_from_row(row) for row in cursor.fetchall()]
|
||||
|
||||
today = datetime.now(tz).date().isoformat()
|
||||
cursor.execute("SELECT * FROM journal_entry WHERE date = %s", (today,))
|
||||
cursor.execute("SELECT * FROM journal_entry WHERE date = ?" if DB_TYPE == 'sqlite' else "SELECT * FROM journal_entry WHERE date = %s", (today,))
|
||||
todays_entry = cursor.fetchone()
|
||||
if DB_TYPE == 'sqlite' and todays_entry:
|
||||
todays_entry = dict_from_row(todays_entry)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
has_prev = page > 1
|
||||
@@ -107,12 +151,13 @@ def add_entry():
|
||||
if content.strip():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id FROM journal_entry WHERE date = %s", (date,))
|
||||
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
|
||||
cursor.execute(f"SELECT id FROM journal_entry WHERE date = {param_marker}", (date,))
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
cursor.execute("UPDATE journal_entry SET content = %s WHERE date = %s", (content, date))
|
||||
cursor.execute(f"UPDATE journal_entry SET content = {param_marker} WHERE date = {param_marker}", (content, date))
|
||||
else:
|
||||
cursor.execute("INSERT INTO journal_entry (date, content) VALUES (%s, %s)", (date, content))
|
||||
cursor.execute(f"INSERT INTO journal_entry (date, content) VALUES ({param_marker}, {param_marker})", (date, content))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -124,30 +169,49 @@ def entry_for_date():
|
||||
if not date:
|
||||
return jsonify({'content': ''})
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT content FROM journal_entry WHERE date = %s", (date,))
|
||||
cursor = conn.cursor()
|
||||
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
|
||||
cursor.execute(f"SELECT content FROM journal_entry WHERE date = {param_marker}", (date,))
|
||||
entry = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'content': entry['content'] if entry else ''})
|
||||
if DB_TYPE == 'sqlite':
|
||||
content = entry[0] if entry else ''
|
||||
else:
|
||||
content = entry['content'] if entry else ''
|
||||
return jsonify({'content': content})
|
||||
|
||||
@app.route('/search', methods=['GET'])
|
||||
def search():
|
||||
query = request.args.get('query', '')
|
||||
date = request.args.get('date', None)
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
sql = "SELECT * FROM journal_entry WHERE 1=1"
|
||||
params = []
|
||||
if query:
|
||||
sql += " AND content LIKE %s"
|
||||
params.append(f"%{query}%")
|
||||
if date:
|
||||
sql += " AND date = %s"
|
||||
params.append(date)
|
||||
sql += " ORDER BY date DESC"
|
||||
cursor.execute(sql, params)
|
||||
entries = cursor.fetchall()
|
||||
cursor = conn.cursor()
|
||||
if DB_TYPE == 'mysql':
|
||||
sql = "SELECT * FROM journal_entry WHERE 1=1"
|
||||
params = []
|
||||
if query:
|
||||
sql += " AND content LIKE %s"
|
||||
params.append(f"%{query}%")
|
||||
if date:
|
||||
sql += " AND date = %s"
|
||||
params.append(date)
|
||||
sql += " ORDER BY date DESC"
|
||||
cursor.execute(sql, params)
|
||||
entries = cursor.fetchall()
|
||||
elif DB_TYPE == 'sqlite':
|
||||
sql = "SELECT * FROM journal_entry WHERE 1=1"
|
||||
params = []
|
||||
if query:
|
||||
sql += " AND content LIKE ?"
|
||||
params.append(f"%{query}%")
|
||||
if date:
|
||||
sql += " AND date = ?"
|
||||
params.append(date)
|
||||
sql += " ORDER BY date DESC"
|
||||
cursor.execute(sql, params)
|
||||
entries = [dict_from_row(row) for row in cursor.fetchall()]
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
today = datetime.now(tz).date().isoformat()
|
||||
@@ -186,22 +250,26 @@ def require_login():
|
||||
@app.route('/edit/<int:entry_id>', methods=['GET', 'POST'])
|
||||
def edit_entry(entry_id):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor = conn.cursor()
|
||||
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
|
||||
|
||||
if request.method == 'POST':
|
||||
date = request.form.get('date')
|
||||
content = request.form.get('content')
|
||||
cursor.execute("UPDATE journal_entry SET date=%s, content=%s WHERE id=%s", (date, content, entry_id))
|
||||
cursor.execute(f"UPDATE journal_entry SET date={param_marker}, content={param_marker} WHERE id={param_marker}", (date, content, entry_id))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
cursor.execute("SELECT * FROM journal_entry WHERE id=%s", (entry_id,))
|
||||
cursor.execute(f"SELECT * FROM journal_entry WHERE id={param_marker}", (entry_id,))
|
||||
entry = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
if not entry:
|
||||
return redirect(url_for('index'))
|
||||
if DB_TYPE == 'sqlite':
|
||||
entry = dict_from_row(entry)
|
||||
return render_template('edit.html', entry=entry)
|
||||
|
||||
@app.route('/edit_modal', methods=['POST'])
|
||||
@@ -212,7 +280,8 @@ def edit_entry_modal():
|
||||
if entry_id and date and content:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE journal_entry SET date=%s, content=%s WHERE id=%s", (date, content, entry_id))
|
||||
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
|
||||
cursor.execute(f"UPDATE journal_entry SET date={param_marker}, content={param_marker} WHERE id={param_marker}", (date, content, entry_id))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -222,7 +291,8 @@ def edit_entry_modal():
|
||||
def delete_entry(entry_id):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM journal_entry WHERE id=%s", (entry_id,))
|
||||
param_marker = "?" if DB_TYPE == 'sqlite' else "%s"
|
||||
cursor.execute(f"DELETE FROM journal_entry WHERE id={param_marker}", (entry_id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:latest
|
||||
container_name: echolog-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=root_password
|
||||
- MYSQL_DATABASE=echolog
|
||||
- MYSQL_USER=echolog
|
||||
- MYSQL_PASSWORD=echolog_password
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
echolog:
|
||||
image: cr.jdbnet.co.uk/public/echolog:latest
|
||||
container_name: echolog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DB_TYPE=mysql
|
||||
- MYSQL_HOST=mysql
|
||||
- MYSQL_USER=echolog
|
||||
- MYSQL_PASSWORD=echolog_password
|
||||
- MYSQL_DATABASE=echolog
|
||||
- SECRET_KEY=change-me-in-production
|
||||
- TZ=Europe/London
|
||||
- LOGIN_ENABLED=false
|
||||
# Uncomment below to enable login protection
|
||||
# - LOGIN_ENABLED=true
|
||||
# - LOGIN_USERNAME=admin
|
||||
# - LOGIN_PASSWORD=change-me
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
@@ -0,0 +1,24 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
echolog:
|
||||
image: cr.jdbnet.co.uk/public/echolog:latest
|
||||
container_name: echolog
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- echolog-data:/data
|
||||
environment:
|
||||
- DB_TYPE=sqlite
|
||||
- SQLITE_DB=/data/echolog.db
|
||||
- SECRET_KEY=change-me-in-production
|
||||
- TZ=Europe/London
|
||||
- LOGIN_ENABLED=false
|
||||
# Uncomment below to enable login protection
|
||||
# - LOGIN_ENABLED=true
|
||||
# - LOGIN_USERNAME=admin
|
||||
# - LOGIN_PASSWORD=change-me
|
||||
|
||||
volumes:
|
||||
echolog-data:
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
Flask
|
||||
mysql-connector-python
|
||||
python-dotenv
|
||||
pytz
|
||||
gunicorn
|
||||
gunicorn
|
||||
# Optional database drivers:
|
||||
# For MySQL/MariaDB support, uncomment the line below:
|
||||
# mysql-connector-python
|
||||
# SQLite is built-in with Python
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
<footer class="mt-8 py-6 border-t border-gray-800 text-center text-gray-500 text-sm">
|
||||
<span class="mr-1"><a href="https://www.jdbnet.co.uk" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">JDB-NET</a> © {{ now.year }}</span>
|
||||
·
|
||||
<span class="mx-1"><a href="https://github.com/JDB-NET/echolog" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">{{ version }}</a></span>
|
||||
<span class="mx-1"><a href="https://git.jdbnet.co.uk/jamie/echolog" target="_blank" rel="noopener" class="text-gray-500 hover:text-pink-400 hover:underline mx-1">{{ version }}</a></span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user