nowe funkcje i foxy

This commit is contained in:
Mateusz Gruszczyński
2025-07-25 18:27:58 +02:00
parent bb667a2cbd
commit e4322f2bc6
10 changed files with 267 additions and 87 deletions

83
app.py
View File

@@ -295,7 +295,9 @@ def save_resized_image(file, path):
image.info.clear()
new_path = path.rsplit(".", 1)[0] + ".webp"
image.save(new_path, format="WEBP", quality=100, method=0)
# image.save(new_path, format="WEBP", quality=100, method=0)
image.save(new_path, format="WEBP", lossless=True, method=6)
except Exception as e:
raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}")
@@ -662,13 +664,34 @@ def favicon():
@app.route("/")
def main_page():
# now = datetime.utcnow()
now = datetime.now(timezone.utc)
month_str = request.args.get("month")
start = end = None
if month_str:
try:
year, month = map(int, month_str.split("-"))
start = datetime(year, month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
except:
start = end = None
def date_filter(query):
if start and end:
query = query.filter(
ShoppingList.created_at >= start, ShoppingList.created_at < end
)
return query
if current_user.is_authenticated:
user_lists = (
ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False)
.filter((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now))
date_filter(
ShoppingList.query.filter_by(
owner_id=current_user.id, is_archived=False
).filter(
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
@@ -680,11 +703,16 @@ def main_page():
)
public_lists = (
ShoppingList.query.filter(
ShoppingList.is_public == True,
ShoppingList.owner_id != current_user.id,
((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)),
ShoppingList.is_archived == False,
date_filter(
ShoppingList.query.filter(
ShoppingList.is_public == True,
ShoppingList.owner_id != current_user.id,
(
(ShoppingList.expires_at == None)
| (ShoppingList.expires_at > now)
),
ShoppingList.is_archived == False,
)
)
.order_by(ShoppingList.created_at.desc())
.all()
@@ -693,10 +721,15 @@ def main_page():
user_lists = []
archived_lists = []
public_lists = (
ShoppingList.query.filter(
ShoppingList.is_public == True,
((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)),
ShoppingList.is_archived == False,
date_filter(
ShoppingList.query.filter(
ShoppingList.is_public == True,
(
(ShoppingList.expires_at == None)
| (ShoppingList.expires_at > now)
),
ShoppingList.is_archived == False,
)
)
.order_by(ShoppingList.created_at.desc())
.all()
@@ -710,6 +743,8 @@ def main_page():
user_lists=user_lists,
public_lists=public_lists,
archived_lists=archived_lists,
now=now,
timedelta=timedelta,
)
@@ -1028,16 +1063,24 @@ def user_expenses():
)
@app.route("/user/expenses_data")
@app.route("/user_expenses_data")
@login_required
def user_expenses_data():
range_type = request.args.get("range", "monthly")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
show_all = request.args.get("show_all", "false").lower() == "true"
query = Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id).filter(
ShoppingList.owner_id == current_user.id
)
query = Expense.query.join(ShoppingList, Expense.list_id == ShoppingList.id)
if show_all:
query = query.filter(
or_(
ShoppingList.owner_id == current_user.id, ShoppingList.is_public == True
)
)
else:
query = query.filter(ShoppingList.owner_id == current_user.id)
if start_date and end_date:
try:
@@ -2110,15 +2153,15 @@ def crop_receipt():
old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
try:
image = Image.open(file).convert("RGB")
new_filename = generate_new_receipt_filename(receipt.list_id)
new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename)
image.save(new_path, format="WEBP", quality=100)
save_resized_image(file, new_path)
if os.path.exists(old_path):
os.remove(old_path)
receipt.filename = new_filename
receipt.filename = os.path.basename(new_path)
db.session.commit()
return jsonify(success=True)

View File

@@ -205,7 +205,6 @@ input.form-control {
box-shadow: 0 -1px 4px rgba(0, 0, 0, 0.25);
}
@media (max-width: 768px) {
.info-bar-fixed {
position: static;
@@ -310,4 +309,12 @@ input.form-control {
.only-mobile {
display: none !important;
}
}
.sorting-active {
border: 2px dashed #ffc107;
border-radius: 0.5rem;
background-color: rgba(255, 193, 7, 0.05);
padding: 0.5rem;
transition: border 0.3s, background-color 0.3s;
}

View File

@@ -272,7 +272,6 @@ function isListDifferent(oldItems, newItems) {
return false;
}
function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
const li = document.createElement('li');
li.id = `item-${item.id}`;
@@ -297,9 +296,11 @@ function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
let reasonHTML = item.not_purchased_reason
? `<small class="text-dark ms-4">[ <b>Powód: ${item.not_purchased_reason}</b> ]</small>` : '';
let dragHandle = window.isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : '';
let left = `
<div class="d-flex align-items-center gap-2 flex-grow-1">
${window.isSorting ? `<span class="drag-handle me-2 text-danger" style="cursor: grab;">☰</span>` : ''}
${dragHandle}
${checkboxOrIcon}
<span id="name-${item.id}" class="text-white">${item.name} ${quantityBadge}</span>
${noteHTML}
@@ -361,8 +362,6 @@ function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) {
return li;
}
function updateListSmoothly(newItems) {
const itemsContainer = document.getElementById('items');
const existingItemsMap = new Map();

View File

@@ -4,22 +4,22 @@ let currentReceiptId;
document.addEventListener("DOMContentLoaded", function () {
const cropModal = document.getElementById("cropModal");
const cropImage = document.getElementById("cropImage");
const spinner = document.getElementById("cropLoading");
cropModal.addEventListener("shown.bs.modal", function (event) {
const button = event.relatedTarget;
const imgSrc = button.getAttribute("data-img-src");
currentReceiptId = button.getAttribute("data-receipt-id");
const image = document.getElementById("cropImage");
image.src = imgSrc;
cropImage.src = imgSrc;
if (cropper) {
cropper.destroy();
cropper = null;
}
image.onload = () => {
cropper = new Cropper(image, {
cropImage.onload = () => {
cropper = new Cropper(cropImage, {
viewMode: 1,
autoCropArea: 1,
responsive: true,
@@ -36,7 +36,51 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("saveCrop").addEventListener("click", function () {
if (!cropper) return;
cropper.getCroppedCanvas().toBlob(function (blob) {
spinner.classList.remove("d-none");
const cropData = cropper.getData();
const imageData = cropper.getImageData();
const scaleX = imageData.naturalWidth / imageData.width;
const scaleY = imageData.naturalHeight / imageData.height;
const width = cropData.width * scaleX;
const height = cropData.height * scaleY;
if (width < 1 || height < 1) {
spinner.classList.add("d-none");
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
return;
}
// Ogranicz do 2000x2000 w proporcji
const maxDim = 2000;
const scale = Math.min(1, maxDim / Math.max(width, height));
const finalWidth = Math.round(width * scale);
const finalHeight = Math.round(height * scale);
const croppedCanvas = cropper.getCroppedCanvas({
width: finalWidth,
height: finalHeight,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
if (!croppedCanvas) {
spinner.classList.add("d-none");
showToast("Nie można uzyskać obrazu przycięcia", "danger");
return;
}
croppedCanvas.toBlob(function (blob) {
if (!blob) {
spinner.classList.add("d-none");
showToast("Nie udało się zapisać obrazu", "danger");
return;
}
const formData = new FormData();
formData.append("receipt_id", currentReceiptId);
formData.append("cropped_image", blob);
@@ -47,6 +91,7 @@ document.addEventListener("DOMContentLoaded", function () {
})
.then((res) => res.json())
.then((data) => {
spinner.classList.add("d-none");
if (data.success) {
showToast("Zapisano przycięty paragon", "success");
setTimeout(() => location.reload(), 1500);
@@ -55,9 +100,10 @@ document.addEventListener("DOMContentLoaded", function () {
}
})
.catch((err) => {
spinner.classList.add("d-none");
showToast("Błąd sieci", "danger");
console.error(err);
});
}, "image/webp");
}, "image/webp", 1.0);
});
});

14
static/js/select_month.js Normal file
View File

@@ -0,0 +1,14 @@
document.addEventListener("DOMContentLoaded", () => {
const select = document.getElementById("monthSelect");
if (!select) return;
select.addEventListener("change", () => {
const month = select.value;
const url = new URL(window.location.href);
if (month) {
url.searchParams.set("month", month);
} else {
url.searchParams.delete("month");
}
window.location.href = url.toString();
});
});

View File

@@ -2,53 +2,54 @@ let sortable = null;
let isSorting = false;
function enableSortMode() {
if (sortable || isSorting) return;
if (isSorting) return;
isSorting = true;
window.isSorting = true;
localStorage.setItem('sortModeEnabled', 'true');
const itemsContainer = document.getElementById('items');
const listId = window.LIST_ID;
if (!itemsContainer || !listId) return;
sortable = Sortable.create(itemsContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
filter: 'input, button',
preventOnFilter: false,
onEnd: function () {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', '')))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ list_id: listId, order })
}).then(() => {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order.map(id =>
window.currentItems.find(item => item.id === id)
);
updateListSmoothly(window.currentItems);
}
});
}
});
const btn = document.getElementById('sort-toggle-btn');
if (btn) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
}
// Odśwież widok listy z uchwytami (☰)
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
// Poczekaj na DOM po odświeżeniu listy
setTimeout(() => {
if (sortable) sortable.destroy();
sortable = Sortable.create(itemsContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
filter: 'input, button',
preventOnFilter: false,
onEnd: () => {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', '')))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ list_id: listId, order })
}).then(() => {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order.map(id =>
window.currentItems.find(item => item.id === id)
);
updateListSmoothly(window.currentItems);
}
});
}
});
updateSortButtonUI(true);
}, 50);
}
function disableSortMode() {
@@ -56,28 +57,40 @@ function disableSortMode() {
sortable.destroy();
sortable = null;
}
isSorting = false;
localStorage.removeItem('sortModeEnabled');
const btn = document.getElementById('sort-toggle-btn');
if (btn) {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
window.isSorting = false;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
updateSortButtonUI(false);
}
function toggleSortMode() {
isSorting ? disableSortMode() : enableSortMode();
}
function updateSortButtonUI(active) {
const btn = document.getElementById('sort-toggle-btn');
if (!btn) return;
if (active) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
} else {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
}
document.addEventListener('DOMContentLoaded', () => {
const wasSorting = localStorage.getItem('sortModeEnabled') === 'true';
if (wasSorting) {
enableSortMode();
}
});
});

View File

@@ -3,7 +3,11 @@ document.addEventListener("DOMContentLoaded", function () {
const rangeLabel = document.getElementById("chartRangeLabel");
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
let url = '/user/expenses_data?range=' + range;
let url = '/user_expenses_data?range=' + range;
const showAllCheckbox = document.getElementById("showAllLists");
if (showAllCheckbox && showAllCheckbox.checked) {
url += '&show_all=true';
}
if (startDate && endDate) {
url += `&start_date=${startDate}&end_date=${endDate}`;
}

View File

@@ -78,6 +78,11 @@
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
<button class="btn btn-success" id="saveCrop">Zapisz</button>
<div id="cropLoading" class="position-absolute top-50 start-50 translate-middle text-center d-none"
style="z-index: 1055;">
<div class="spinner-border text-light" role="status"></div>
<div class="mt-2 text-light">⏳ Pracuję...</div>
</div>
</div>
</div>
</div>

View File

@@ -31,6 +31,33 @@
</div>
{% endif %}
{% set month_names = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień",
"październik", "listopad", "grudzień"] %}
{% set selected_month = request.args.get('month') or now.strftime('%Y-%m') %}
<!-- Pulpit: zwykły <select> -->
<div class="d-none d-md-flex justify-content-end align-items-center flex-wrap gap-2 mb-3">
<label for="monthSelect" class="text-white small mb-0">📅 Wybierz miesiąc:</label>
<select id="monthSelect" class="form-select form-select-sm bg-dark text-white border-secondary"
style="min-width: 180px;">
{% for offset in range(0, 6) %}
{% set d = (now - timedelta(days=offset * 30)) %}
{% set val = d.strftime('%Y-%m') %}
<option value="{{ val }}" {% if selected_month==val %}selected{% endif %}>
{{ month_names[d.month - 1] }} {{ d.year }}
</option>
{% endfor %}
<option value="">Wyświetl wszystko</option>
</select>
</div>
<!-- Telefon: przycisk otwierający modal -->
<div class="d-md-none mb-3">
<button class="btn btn-outline-light w-100" data-bs-toggle="modal" data-bs-target="#monthPickerModal">
📅 Wybierz miesiąc
</button>
</div>
{% if current_user.is_authenticated %}
<h3 class="mt-4 d-flex justify-content-between align-items-center flex-wrap">
Twoje listy
@@ -78,8 +105,7 @@
{% endfor %}
</ul>
{% else %}
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie masz jeszcze żadnych list. Utwórz pierwszą, korzystając
z formularza powyżej</span></p>
<p><span class="badge rounded-pill bg-secondary opacity-75">Nie utworzono żadnej listy</span></p>
{% endif %}
{% endif %}
@@ -114,7 +140,6 @@
<p><span class="badge rounded-pill bg-secondary opacity-75">Brak dostępnych list publicznych do wyświetlenia</span></p>
{% endif %}
<div class="modal fade" id="archivedModal" tabindex="-1" aria-labelledby="archivedModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
@@ -146,8 +171,32 @@
</div>
</div>
<div class="modal fade" id="monthPickerModal" tabindex="-1" aria-labelledby="monthPickerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title">📅 Wybierz miesiąc</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="d-grid gap-2">
{% for offset in range(0, 6) %}
{% set d = (now - timedelta(days=offset * 30)) %}
{% set val = d.strftime('%Y-%m') %}
<a href="{{ url_for('main_page', month=val) }}" class="btn btn-outline-light">
{{ month_names[d.month - 1] }} {{ d.year }}
</a>
{% endfor %}
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">📋 Wyświetl wszystkie</a>
</div>
</div>
</div>
</div>
</div>
{% block scripts %}
<script src="{{ url_for('static_bp.serve_js', filename='toggle_button.js') }}"></script>
<script src="{{ url_for('static_bp.serve_js', filename='select_month.js') }}"></script>
{% endblock %}
{% endblock %}

View File

@@ -6,7 +6,11 @@
<h2 class="mb-2">Statystyki wydatków</h2>
<a href="{{ url_for('main_page') }}" class="btn btn-outline-secondary">← Powrót</a>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
<label class="form-check-label ms-2 text-white" for="showAllLists">Pokaż wszystkie publiczne listy
innych</label>
</div>
<div class="card bg-dark text-white mb-5">
<div class="card-body">
<ul class="nav nav-tabs mb-3" id="expenseTabs" role="tablist">
@@ -47,11 +51,7 @@
<label class="form-check-label ms-2 text-white" for="onlyWithExpenses">Pokaż tylko listy z
wydatkami</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showAllLists" {% if show_all %}checked{% endif %}>
<label class="form-check-label ms-2 text-white" for="showAllLists">Pokaż wszystkie publiczne listy
innych</label>
</div>
<div class="input-group input-group-sm mb-3 w-100" style="max-width: 570px;">
<span class="input-group-text bg-secondary text-white border-secondary">Od</span>