27 Commits
2 ... v2

Author SHA1 Message Date
5e79b6938c delimiter character setup + extended api data 2026-03-01 03:54:05 -03:00
dcd14f1021 Scanner with LF thing to prevent partial scanning 2026-03-01 03:45:37 -03:00
b4344361e4 adding data to scan request + Go ARMv7 2026-03-01 03:23:53 -03:00
fcb75cb5a4 settings file for scanner 2026-03-01 02:21:17 -03:00
676f299796 version bump 2026-02-28 22:41:33 -03:00
751c77cac5 zoom + flash + focus = scanning improvements for iphone and android 2026-02-28 22:39:57 -03:00
85b2c0b4db more theme and camera fixes TwT 2026-02-27 01:11:54 -03:00
741690b30e X fix 2026-02-27 01:06:42 -03:00
7235c7ff09 theme and camera cookie 2026-02-27 00:57:45 -03:00
8cba7937c3 image cache fix 2026-02-27 00:29:25 -03:00
4779452acd image timestamp fix 2026-02-27 00:16:43 -03:00
600df52b04 camera index? 2026-02-27 00:03:55 -03:00
b1b99bc887 webui camera barcode scanner 2026-02-26 23:59:22 -03:00
c7c0b3feb2 picture qol fixes 2026-02-26 23:55:49 -03:00
184f2722bf sort table 2026-02-26 23:38:55 -03:00
0f9966d224 qol and scan fixes 2026-02-26 23:29:45 -03:00
43b2a9e2d5 version bump :p 2026-02-26 22:53:44 -03:00
3a39cb95db re-retriede image if cached one doesnt exist anymore 2026-02-26 22:52:34 -03:00
81cacd3589 bulk delete + more prompts 2026-02-26 22:50:20 -03:00
1f521ec1d2 colors and prompts fix 2026-02-26 22:33:38 -03:00
0dcf0bc930 modified: static/styleIndex.css
modified:   templates/index.html
2026-02-26 21:46:42 -03:00
df4ff9171d Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-02-26 21:16:41 -03:00
1b2e63bc86 added bulk price update feature, allowing users to select multiple products and apply a new price to all of them at once. The bulk action bar now shows the count of selected items and enables the "OK" button only when at least one product is selected. 2026-02-26 21:16:16 -03:00
80bf539484 Merge branch 'main' of https://gitea.sekidesu.xyz/SekiDesu01/SekiPOS 2026-02-26 20:48:56 -03:00
13bba33c26 modified: templates/index.html 2026-02-26 04:21:20 -03:00
ecd98c72ce new file: ScannerPython/output/scannerV2.exe
new file:   ScannerPython/scannerV2.py
2026-02-26 04:10:48 -03:00
344229b77b bump version 2026-02-26 03:35:34 -03:00
11 changed files with 1486 additions and 495 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
pos_database.db
ScannerGO/ScannerGO-*
ScannerGO/config.json

View File

@@ -1,4 +1,4 @@
# SekiPOS v1.2 🍫🥤
# SekiPOS v1.6 🍫🥤
A reactive POS inventory system for software engineers with a snack addiction. Features real-time UI updates, automatic product discovery via Open Food Facts, and local image caching.
@@ -8,6 +8,7 @@ A reactive POS inventory system for software engineers with a snack addiction. F
- **Local Cache:** Saves images locally to `static/cache` to avoid IP bans.
- **CLP Ready:** Chilean Peso formatting ($1.234) for local commerce.
- **Secure:** Hashed password authentication via Flask-Login.
- **On device scanner:** Add and scan products from within your phone!
## 🐳 Docker Deployment (Server)
@@ -23,6 +24,7 @@ docker run -d \
-v $(pwd)/sekipos/db:/app/db \
-v $(pwd)/sekipos/static/cache:/app/static/cache \
--name sekipos-server \
--restart unless-stopped \
sekipos:latest
```
@@ -38,6 +40,7 @@ services:
- YOUR_PATH/sekipos/static/cache:/app/static/cache
container_name: sekipos-server
image: sekipos:latest
restart: unless-stopped
```
## 🔌 Hardware Scanner Bridge (`ScannerGO`)
@@ -80,7 +83,7 @@ python app.py
- `db/pos_database.db`: SQLite storage.
## 📋 TODOs?
- Better admin registration(?)
- Some form of user registration(?)
## 🥼 Food Datasets
- https://www.ifpsglobal.com/plu-codes-search

42
ScannerGO/build.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Define binary names
LINUX_BIN="ScannerGO-linux"
LINUX_ARM_BIN="ScannerGO-linuxARMv7"
WINDOWS_BIN="ScannerGO-windows.exe"
echo "Starting build process..."
# Build for Linux (64-bit)
echo "Building for Linux..."
GOOS=linux GOARCH=amd64 go build -o "$LINUX_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $LINUX_BIN"
else
echo "Failed to build Linux binary"
exit 1
fi
# Build for Windows (64-bit)
echo "Building for Windows..."
GOOS=windows GOARCH=amd64 go build -o "$WINDOWS_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $WINDOWS_BIN"
else
echo "Failed to build Windows binary"
exit 1
fi
# Build for Linux ARM (ARMv7)
echo "Building for Linux ARMv7..."
GOOS=linux GOARCH=arm GOARM=7 go build -o "$LINUX_ARM_BIN" main.go
if [ $? -eq 0 ]; then
echo "Successfully built: $LINUX_ARM_BIN"
else
echo "Failed to build Linux ARMv7 binary"
exit 1
fi
echo "Build complete."

View File

@@ -1,6 +1,9 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
@@ -13,45 +16,120 @@ import (
"github.com/tarm/serial"
)
type Config struct {
Port string `json:"port"`
URL string `json:"url"`
BaudRate int `json:"baud"`
Delimiter string `json:"delimiter"`
}
var defaultConfig = Config{
Port: "/dev/ttyACM0",
URL: "https://scanner.sekidesu.xyz/scan",
BaudRate: 115200,
Delimiter: "\n",
}
const configPath = "config.json"
func main() {
portName := flag.String("port", "/dev/ttyACM0", "Serial port name")
endpoint := flag.String("url", "https://scanner.sekidesu.xyz/scan", "Target URL endpoint")
baudRate := flag.Int("baud", 115200, "Baud rate")
cfg := loadConfig()
portName := flag.String("port", cfg.Port, "Serial port name")
endpoint := flag.String("url", cfg.URL, "Target URL endpoint")
baudRate := flag.Int("baud", cfg.BaudRate, "Baud rate")
delim := flag.String("delim", cfg.Delimiter, "Line delimiter")
save := flag.Bool("save", false, "Save current parameters to config.json")
flag.Parse()
config := &serial.Config{
Name: *portName,
Baud: *baudRate,
ReadTimeout: time.Second * 2,
cfg.Port = *portName
cfg.URL = *endpoint
cfg.BaudRate = *baudRate
cfg.Delimiter = *delim
if *save {
saveConfig(cfg)
fmt.Println("Settings saved to", configPath)
}
port, err := serial.OpenPort(config)
serialConfig := &serial.Config{
Name: cfg.Port,
Baud: cfg.BaudRate,
ReadTimeout: 0,
}
port, err := serial.OpenPort(serialConfig)
if err != nil {
fmt.Printf("Error opening port %s: %v\n", *portName, err)
fmt.Printf("Error opening port %s: %v\n", cfg.Port, err)
os.Exit(1)
}
defer port.Close()
fmt.Printf("Listening on %s (Baud: %d)...\n", *portName, *baudRate)
fmt.Printf("Sending data to: %s\n", *endpoint)
fmt.Printf("Listening on %s (Baud: %d, Delim: %q)...\n", cfg.Port, cfg.BaudRate, cfg.Delimiter)
buf := make([]byte, 128)
for {
n, err := port.Read(buf)
if err != nil {
if err != io.EOF {
fmt.Printf("Read error: %v\n", err)
}
continue
scanner := bufio.NewScanner(port)
// Custom split function to handle the configurable delimiter
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.Index(data, []byte(cfg.Delimiter)); i >= 0 {
return i + len(cfg.Delimiter), data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
if n > 0 {
content := strings.TrimSpace(string(buf[:n]))
if content != "" {
sendToEndpoint(*endpoint, content)
}
for scanner.Scan() {
content := strings.TrimSpace(scanner.Text())
if content != "" {
sendToEndpoint(cfg.URL, content)
}
}
if err := scanner.Err(); err != nil {
fmt.Printf("Scanner error: %v\n", err)
}
}
func loadConfig() Config {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
saveConfig(defaultConfig)
return defaultConfig
}
file, err := os.Open(configPath)
if err != nil {
return defaultConfig
}
defer file.Close()
var cfg Config
decoder := json.NewDecoder(file)
if err := decoder.Decode(&cfg); err != nil {
return defaultConfig
}
// Handle case where JSON exists but field is missing
if cfg.Delimiter == "" {
cfg.Delimiter = "\n"
}
return cfg
}
func saveConfig(cfg Config) {
file, err := os.Create(configPath)
if err != nil {
fmt.Printf("Failed to create/save config: %v\n", err)
return
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
encoder.Encode(cfg)
}
func sendToEndpoint(baseURL, content string) {
@@ -60,7 +138,6 @@ func sendToEndpoint(baseURL, content string) {
}
fullURL := fmt.Sprintf("%s?content=%s", baseURL, url.QueryEscape(content))
resp, err := client.Get(fullURL)
if err != nil {
fmt.Printf("Network Error: %v\n", err)
@@ -68,5 +145,15 @@ func sendToEndpoint(baseURL, content string) {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
return
}
fmt.Printf("Data: [%s] | Status: %s\n", content, resp.Status)
if len(body) > 0 {
fmt.Printf("Response: %s\n", string(body))
}
fmt.Println(strings.Repeat("-", 30))
}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
import serial
import requests
import time
import argparse
import sys
def run_bridge():
parser = argparse.ArgumentParser(description="Scanner Bridge for the technically impaired")
parser.add_argument('--port', default='COM5', help='Serial port (default: COM5)')
parser.add_argument('--baud', type=int, default=115200, help='Baud rate (default: 115200)')
parser.add_argument('--url', default='https://scanner.sekidesu.xyz/scan', help='Server URL')
args = parser.parse_args()
try:
ser = serial.Serial(args.port, args.baud, timeout=0.1)
print(f"Connected to {args.port} at {args.baud} bauds.")
while True:
if ser.in_waiting > 0:
barcode = ser.readline().decode('utf-8', errors='ignore').strip()
if barcode:
print(f"Scanned: {barcode}")
try:
resp = requests.get(args.url, params={'content': barcode}, timeout=5)
print(f"Server responded: {resp.status_code}")
except Exception as e:
print(f"Failed to send to server: {e}")
time.sleep(0.01)
except serial.SerialException as e:
print(f"Error opening {args.port}: {e}")
except KeyboardInterrupt:
print("\nBridge stopped. Finally.")
if __name__ == "__main__":
run_bridge()

128
app.py
View File

@@ -6,6 +6,7 @@ from flask_socketio import SocketIO, emit
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
import mimetypes
import time
app = Flask(__name__)
app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends
@@ -74,11 +75,6 @@ def download_image(url, barcode):
return url
def fetch_from_openfoodfacts(barcode):
# Check if we already have a cached image even if API fails
local_filename = f"{barcode}.jpg"
local_path = os.path.join(CACHE_DIR, local_filename)
cached_url = f"/static/cache/{local_filename}" if os.path.exists(local_path) else None
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
try:
headers = {'User-Agent': 'SekiPOS/1.0'}
@@ -89,16 +85,14 @@ def fetch_from_openfoodfacts(barcode):
name = p.get('product_name_es') or p.get('product_name') or p.get('brands', 'Unknown')
imgs = p.get('selected_images', {}).get('front', {}).get('display', {})
img_url = imgs.get('es') or imgs.get('en') or p.get('image_url', '')
local_img = download_image(img_url, barcode)
return {"name": name, "image": local_img}
if img_url:
local_img = download_image(img_url, barcode)
return {"name": name, "image": local_img}
return {"name": name, "image": None}
except Exception as e:
print(f"API Error: {e}")
# If API fails but we have a cache, return the cache with a generic name
if cached_url:
return {"name": "Producto Cacheado", "image": cached_url}
return None
# --- ROUTES ---
@@ -163,32 +157,122 @@ def delete(barcode):
@app.route('/scan', methods=['GET'])
def scan():
barcode = request.args.get('content', '').replace('{content}', '')
if not barcode: return jsonify({"err": "empty"}), 400
if not barcode:
return jsonify({"status": "error", "message": "empty barcode"}), 400
with sqlite3.connect(DB_FILE) as conn:
p = conn.execute('SELECT * FROM products WHERE barcode = ?', (barcode,)).fetchone()
# 1. Product exists in local Database
if p:
socketio.emit('new_scan', {"barcode": p[0], "name": p[1], "price": int(p[2]), "image": p[3]})
return jsonify({"status": "ok"})
barcode_val, name, price, image_path = p
# Not in DB, try external API or Local Cache
# Image recovery logic for missing local files
if image_path and image_path.startswith('/static/'):
clean_path = image_path.split('?')[0].lstrip('/')
if not os.path.exists(clean_path):
ext_data = fetch_from_openfoodfacts(barcode_val)
if ext_data and ext_data.get('image'):
image_path = ext_data['image']
with sqlite3.connect(DB_FILE) as conn:
conn.execute('UPDATE products SET image_url = ? WHERE barcode = ?', (image_path, barcode_val))
conn.commit()
product_data = {
"barcode": barcode_val,
"name": name,
"price": int(price),
"image": image_path
}
socketio.emit('new_scan', product_data)
return jsonify({"status": "ok", "data": product_data}), 200
# 2. Product not in DB, try external API
ext = fetch_from_openfoodfacts(barcode)
if ext:
socketio.emit('scan_error', {
# We found it externally, but it's still a 404 relative to our local DB
external_data = {
"barcode": barcode,
"name": ext['name'],
"image": ext['image']
})
else:
socketio.emit('scan_error', {"barcode": barcode})
"image": ext['image'],
"source": "openfoodfacts"
}
socketio.emit('scan_error', external_data)
return jsonify({"status": "not_found", "data": external_data}), 404
return jsonify({"status": "not_found"}), 404
# 3. Truly not found anywhere
socketio.emit('scan_error', {"barcode": barcode})
return jsonify({"status": "not_found", "data": {"barcode": barcode}}), 404
@app.route('/static/cache/<path:filename>')
def serve_cache(filename):
return send_from_directory(CACHE_DIR, filename)
@app.route('/bulk_price_update', methods=['POST'])
@login_required
def bulk_price_update():
data = request.get_json()
barcodes = data.get('barcodes', [])
new_price = data.get('new_price')
if not barcodes or new_price is None:
return jsonify({"error": "Missing data"}), 400
try:
with sqlite3.connect(DB_FILE) as conn:
# Use executemany for efficiency
params = [(float(new_price), b) for b in barcodes]
conn.executemany('UPDATE products SET price = ? WHERE barcode = ?', params)
conn.commit()
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk update failed: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/bulk_delete', methods=['POST'])
@login_required
def bulk_delete():
data = request.get_json()
barcodes = data.get('barcodes', [])
if not barcodes:
return jsonify({"error": "No barcodes provided"}), 400
try:
with sqlite3.connect(DB_FILE) as conn:
# Delete records from DB
conn.execute(f'DELETE FROM products WHERE barcode IN ({",".join(["?"]*len(barcodes))})', barcodes)
conn.commit()
# Clean up cache for each deleted product
for barcode in barcodes:
# This is a bit naive as it only checks .jpg, but matches your existing delete logic
img_p = os.path.join(CACHE_DIR, f"{barcode}.jpg")
if os.path.exists(img_p):
os.remove(img_p)
return jsonify({"status": "success"}), 200
except Exception as e:
print(f"Bulk delete failed: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/upload_image', methods=['POST'])
@login_required
def upload_image():
if 'image' not in request.files or 'barcode' not in request.form:
return jsonify({"error": "Missing data"}), 400
file = request.files['image']
barcode = request.form['barcode']
if file.filename == '' or not barcode:
return jsonify({"error": "Invalid data"}), 400
ext = mimetypes.guess_extension(file.mimetype) or '.jpg'
filename = f"{barcode}{ext}"
filepath = os.path.join(CACHE_DIR, filename)
file.save(filepath)
timestamp = int(time.time())
return jsonify({"status": "success", "image_url": f"/static/cache/{filename}?t={timestamp}"}), 200
if __name__ == '__main__':
init_db()
socketio.run(app, host='0.0.0.0', port=5000, debug=True)

View File

@@ -1,182 +0,0 @@
:root {
/* Discord Light Mode Palette */
--bg: #ebedef;
--card-bg: #ffffff;
--text-main: #2e3338;
--text-muted: #4f5660;
--border: #e3e5e8;
--header-bg: #ffffff;
--input-bg: #e3e5e8;
--table-head: #f2f3f5;
--price-color: #2e3338;
--accent: #5865F2;
/* Discord Burple */
--accent-hover: #4752c4;
--danger: #ed4245;
--warning: #fee75c;
}
[data-theme="dark"] {
/* Discord Dark Mode Palette */
--bg: #36393f;
--card-bg: #2f3136;
--text-main: #ffffff;
--text-muted: #b9bbbe;
--border: #202225;
--header-bg: #202225;
--input-bg: #202225;
--table-head: #292b2f;
--price-color: #ffffff;
}
body {
font-family: 'gg sans', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
padding: 20px;
background: var(--bg);
color: var(--text-main);
margin: 0;
transition: background 0.2s, color 0.2s;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--header-bg);
padding: 10px 25px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
}
.main-container {
display: flex;
gap: 25px;
}
.column {
flex: 1;
min-width: 400px;
}
.card {
background: var(--card-bg);
padding: 25px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
margin-bottom: 20px;
border: 1px solid var(--border);
}
#display-img {
max-width: 250px;
max-height: 250px;
border-radius: 4px;
margin-bottom: 15px;
object-fit: contain;
}
.price-tag {
font-size: 3em;
color: var(--price-color);
font-weight: 800;
margin: 10px 0;
}
input {
display: block;
width: 100%;
margin: 12px 0;
padding: 12px;
border: none;
border-radius: 4px;
box-sizing: border-box;
background: var(--input-bg);
color: var(--text-main);
}
button {
padding: 10px 15px;
cursor: pointer;
border-radius: 4px;
border: none;
transition: 0.2s;
font-weight: 500;
}
.btn-save {
background: var(--accent);
color: white;
width: 100%;
font-size: 1.1em;
}
.btn-save:hover {
background: var(--accent-hover);
}
.btn-edit {
background: var(--accent);
color: white;
}
.btn-del {
background: var(--danger);
color: white;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
}
th,
td {
padding: 15px;
border-bottom: 1px solid var(--border);
text-align: left;
}
th {
background: var(--table-head);
color: var(--text-muted);
text-transform: uppercase;
font-size: 0.8em;
letter-spacing: 0.5px;
}
.theme-toggle-btn {
background: var(--text-main);
color: var(--bg);
font-weight: bold;
}
#new-product-prompt {
display: none;
background: var(--accent);
color: white;
padding: 12px 20px;
border-radius: 8px;
margin-bottom: 20px;
animation: slideDown 0.4s ease;
justify-content: space-between;
align-items: center;
}
@keyframes slideDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -1,98 +0,0 @@
:root {
/* Discord Branding Colors */
--bg: #5865F2;
--card-bg: #ffffff;
--text: #2e3338;
--input-bg: #e3e5e8;
--input-border: transparent;
--accent: #5865F2;
--accent-hover: #4752c4;
--error: #ed4245;
}
[data-theme="dark"] {
/* Discord Dark Mode */
--bg: #36393f;
--card-bg: #2f3136;
--text: #ffffff;
--input-bg: #202225;
--input-border: transparent;
}
body {
font-family: 'gg sans', 'Segoe UI', Tahoma, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: var(--bg);
margin: 0;
transition: background 0.2s;
}
.login-box {
background: var(--card-bg);
padding: 32px;
border-radius: 8px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
text-align: center;
color: var(--text);
width: 400px;
transition: background 0.2s, color 0.2s;
}
h2 {
margin-top: 0;
font-weight: 700;
font-size: 24px;
}
p.sub-text {
color: var(--text);
opacity: 0.7;
margin-bottom: 20px;
}
input {
display: block;
width: 100%;
margin: 16px 0;
padding: 12px;
border: none;
border-radius: 4px;
background: var(--input-bg);
color: var(--text);
box-sizing: border-box;
font-size: 16px;
}
input:focus {
outline: 2px solid var(--accent);
}
button {
background: var(--accent);
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
width: 100%;
margin-top: 10px;
transition: background 0.2s;
}
button:hover {
background: var(--accent-hover);
}
.error-msg {
background: var(--error);
color: white;
padding: 8px;
border-radius: 4px;
font-size: 0.9em;
margin-bottom: 15px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,172 @@
<!DOCTYPE html>
<html lang="es">
<html lang="es" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SekiPOS Login</title>
<link rel="shortcut icon" href="./static/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="./static/styleLogin.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {
--bg: #5865f2;
--card-bg: #ffffff;
--text: #2e3338;
--input-bg: #e3e5e8;
--accent: #5865f2;
--accent-hover: #4752c4;
--error: #ed4245;
}
[data-theme="dark"] {
--bg: #36393f;
--card-bg: #2f3136;
--text: #ffffff;
--input-bg: #202225;
}
body {
background: var(--bg);
font-family: "gg sans", "Segoe UI", sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
margin: 0;
padding: 16px;
}
.login-box {
background: var(--card-bg);
color: var(--text);
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
transition: background 0.2s, color 0.2s;
}
.form-control {
background: var(--input-bg) !important;
color: var(--text) !important;
border: none;
}
.form-control:focus {
box-shadow: 0 0 0 2px var(--accent);
}
.form-control::placeholder {
color: #8a8e94;
}
.btn-login {
background: var(--accent);
color: #fff;
border: none;
font-weight: 600;
}
.btn-login:hover {
background: var(--accent-hover);
color: #fff;
}
.error-alert {
background: var(--error);
color: #fff;
border-radius: 4px;
font-size: .88rem;
}
</style>
</head>
<body>
<div class="login-box">
<h2>SekiPOS</h2>
<p class="sub-text">¡Hola de nuevo!</p>
<div class="login-box text-center">
<h2 class="fw-bold mb-1">SekiPOS</h2>
<p class="mb-4" style="opacity:.7;">¡Hola de nuevo!</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="error-msg">{{ messages[0] }}</div>
{% endif %}
{% if messages %}
<div class="error-alert p-2 mb-3">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<form method="POST">
<input type="text" name="username" placeholder="Usuario" required>
<input type="password" name="password" placeholder="Contraseña" required>
<button type="submit">Iniciar Sesión</button>
<input class="form-control mb-3" type="text" name="username" placeholder="Usuario" required autofocus>
<input class="form-control mb-3" type="password" name="password" placeholder="Contraseña" required>
<button type="submit" class="btn btn-login w-100">
Iniciar Sesión
</button>
</form>
</div>
<script>
// Sync theme with main page
const savedTheme = localStorage.getItem('theme') || 'light';
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
/* ── Theme Management ── */
// Helper to set a cookie
function setCookie(name, value, days = 365) {
const d = new Date();
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
let expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/;SameSite=Lax";
}
// Helper to get a cookie
function getCookie(name) {
let nameEQ = name + "=";
let ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
const isDark = t === 'dark';
const themeIcon = document.getElementById('theme-icon');
const themeLabel = document.getElementById('theme-label');
if (themeIcon) themeIcon.className = isDark ? 'bi bi-sun me-2' : 'bi bi-moon-stars me-2';
if (themeLabel) themeLabel.innerText = isDark ? 'Modo Claro' : 'Modo Oscuro';
setCookie('theme', t);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
}
// Initialization Logic
function initTheme() {
const savedTheme = getCookie('theme');
if (savedTheme) {
// Use user preference if it exists
applyTheme(savedTheme);
} else {
// Otherwise, detect OS preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(prefersDark ? 'dark' : 'light');
}
}
// Listen for OS theme changes in real-time if no cookie is set
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!getCookie('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
initTheme();
</script>
</body>
</html>