diff --git a/apps/desktop/scripts/build_raven_icon.py b/apps/desktop/scripts/build_raven_icon.py new file mode 100644 index 0000000..52f3577 --- /dev/null +++ b/apps/desktop/scripts/build_raven_icon.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Generate optimized icon PNGs (32/64/128/256) with a tighter composition and rebuild icon.ico. + +This script avoids external imaging dependencies by parsing and writing PNGs directly. +""" + +from __future__ import annotations + +import math +import struct +import zlib +from binascii import crc32 +from pathlib import Path + +ICON_DIR = Path(__file__).resolve().parents[1] / "src-tauri" / "icons" +BASE_TRANSPARENT = ICON_DIR / "logo-raven.png" +BACKGROUND_SOURCE = ICON_DIR / "logo-raven-fund-azul.png" + +TARGET_SIZES = [32, 64, 128, 256, 512] +FILL_RATIO = 0.9 # portion of the canvas occupied by the raven figure + + +def read_png(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]]]: + data = path.read_bytes() + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError(f"{path} is not a PNG") + pos = 8 + width = height = bit_depth = color_type = None + chunks: list[bytes] = [] + while pos < len(data): + length = struct.unpack(">I", data[pos : pos + 4])[0] + pos += 4 + ctype = data[pos : pos + 4] + pos += 4 + chunk = data[pos : pos + length] + pos += length + pos += 4 # CRC, ignored (validated by reader) + if ctype == b"IHDR": + width, height, bit_depth, color_type, _, _, _ = struct.unpack(">IIBBBBB", chunk) + if bit_depth != 8 or color_type not in (2, 6): + raise ValueError("Only 8-bit RGB/RGBA PNGs are supported") + elif ctype == b"IDAT": + chunks.append(chunk) + elif ctype == b"IEND": + break + if width is None or height is None or bit_depth is None or color_type is None: + raise ValueError(f"{path} missing IHDR") + raw = zlib.decompress(b"".join(chunks)) + bpp = 4 if color_type == 6 else 3 + stride = width * bpp + rows = [] + idx = 0 + prev = bytearray(stride) + for _ in range(height): + filter_type = raw[idx] + idx += 1 + row = bytearray(raw[idx : idx + stride]) + idx += stride + if filter_type == 1: # Sub + for i in range(stride): + left = row[i - bpp] if i >= bpp else 0 + row[i] = (row[i] + left) & 0xFF + elif filter_type == 2: # Up + for i in range(stride): + row[i] = (row[i] + prev[i]) & 0xFF + elif filter_type == 3: # Average + for i in range(stride): + left = row[i - bpp] if i >= bpp else 0 + up = prev[i] + row[i] = (row[i] + ((left + up) // 2)) & 0xFF + elif filter_type == 4: # Paeth + for i in range(stride): + left = row[i - bpp] if i >= bpp else 0 + up = prev[i] + up_left = prev[i - bpp] if i >= bpp else 0 + p = left + up - up_left + pa = abs(p - left) + pb = abs(p - up) + pc = abs(p - up_left) + if pa <= pb and pa <= pc: + pr = left + elif pb <= pc: + pr = up + else: + pr = up_left + row[i] = (row[i] + pr) & 0xFF + elif filter_type not in (0,): + raise ValueError(f"Unsupported PNG filter type {filter_type}") + rows.append(bytes(row)) + prev[:] = row + pixels: list[list[tuple[int, int, int, int]]] = [] + for row in rows: + row_pixels = [] + if color_type == 6: + for i in range(0, len(row), 4): + row_pixels.append(tuple(row[i : i + 4])) + else: + for i in range(0, len(row), 3): + r, g, b = row[i : i + 3] + row_pixels.append((r, g, b, 255)) + pixels.append(row_pixels) + return width, height, pixels + + +def write_png(path: Path, width: int, height: int, pixels: list[list[tuple[int, int, int, int]]]) -> None: + raw = bytearray() + for row in pixels: + raw.append(0) # no filter + for r, g, b, a in row: + raw.extend((r & 0xFF, g & 0xFF, b & 0xFF, a & 0xFF)) + compressed = zlib.compress(raw, level=9) + + def chunk(name: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + name + + payload + + struct.pack(">I", crc32(name + payload) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + out = bytearray(b"\x89PNG\r\n\x1a\n") + out += chunk(b"IHDR", ihdr) + out += chunk(b"IDAT", compressed) + out += chunk(b"IEND", b"") + path.write_bytes(out) + + +def crop_content(pixels: list[list[tuple[int, int, int, int]]]) -> list[list[tuple[int, int, int, int]]]: + height = len(pixels) + width = len(pixels[0]) + min_x, min_y = width, height + max_x = max_y = -1 + for y, row in enumerate(pixels): + for x, (_, _, _, a) in enumerate(row): + if a > 0: + if x < min_x: + min_x = x + if y < min_y: + min_y = y + if x > max_x: + max_x = x + if y > max_y: + max_y = y + if max_x < min_x or max_y < min_y: + return pixels + cropped = [row[min_x : max_x + 1] for row in pixels[min_y : max_y + 1]] + return cropped + + +def bilinear_sample(data: list[list[tuple[int, int, int, int]]], x: float, y: float) -> tuple[int, int, int, int]: + height = len(data) + width = len(data[0]) + if x < 0: + x = 0 + if y < 0: + y = 0 + if x > width - 1: + x = width - 1 + if y > height - 1: + y = height - 1 + x0 = int(math.floor(x)) + x1 = min(x0 + 1, width - 1) + y0 = int(math.floor(y)) + y1 = min(y0 + 1, height - 1) + dx = x - x0 + dy = y - y0 + def lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + result = [] + for channel in range(4): + c00 = data[y0][x0][channel] + c10 = data[y0][x1][channel] + c01 = data[y1][x0][channel] + c11 = data[y1][x1][channel] + top = lerp(c00, c10, dx) + bottom = lerp(c01, c11, dx) + value = lerp(top, bottom, dy) + result.append(int(round(value))) + return tuple(result) + + +def resize_with_background( + src: list[list[tuple[int, int, int, int]]], + canvas_size: int, + fill_ratio: float, + background: tuple[int, int, int], +) -> list[list[tuple[int, int, int, int]]]: + src_h = len(src) + src_w = len(src[0]) + scale = fill_ratio * min(canvas_size / src_w, canvas_size / src_h) + dest_w = max(1, int(round(src_w * scale))) + dest_h = max(1, int(round(src_h * scale))) + offset_x = (canvas_size - dest_w) // 2 + offset_y = (canvas_size - dest_h) // 2 + canvas = [ + [(background[0], background[1], background[2], 255) for _ in range(canvas_size)] + for _ in range(canvas_size) + ] + for dy in range(dest_h): + src_y = (dy + 0.5) / scale - 0.5 + for dx in range(dest_w): + src_x = (dx + 0.5) / scale - 0.5 + r, g, b, a = bilinear_sample(src, src_x, src_y) + canvas[offset_y + dy][offset_x + dx] = (r, g, b, a) + return canvas + + +def build_ico(output: Path, png_paths: list[Path]) -> None: + entries = [] + offset = 6 + 16 * len(png_paths) + for path in png_paths: + data = path.read_bytes() + width, height, _ = read_png(path) + entry = { + "width": width if width < 256 else 0, + "height": height if height < 256 else 0, + "colors": 0, + "reserved": 0, + "planes": 1, + "bit_count": 32, + "size": len(data), + "offset": offset, + "data": data, + } + entries.append(entry) + offset += len(data) + + header = struct.pack(" None: + base_w, base_h, base_pixels = read_png(BASE_TRANSPARENT) + background_w, background_h, bg_pixels = read_png(BACKGROUND_SOURCE) + background_color = bg_pixels[background_h // 2][background_w // 2][:3] + + cropped = crop_content(base_pixels) + + generated: list[Path] = [] + for size in TARGET_SIZES: + dest_pixels = resize_with_background(cropped, size, FILL_RATIO, background_color) + out_path = ICON_DIR / f"icon-{size}.png" + write_png(out_path, size, size, dest_pixels) + generated.append(out_path) + print(f"Generated {out_path} ({size}x{size})") + + # Update base PNG used by some bundlers + biggest = max(generated, key=lambda p: int(p.stem.split("-")[-1])) + (ICON_DIR / "icon.png").write_bytes(biggest.read_bytes()) + + # Build ICO with largest size first (common preference) + ordered = sorted( + [p for p in generated if int(p.stem.split("-")[-1]) <= 256], + key=lambda p: int(p.stem.split("-")[-1]), + ) + build_ico(ICON_DIR / "icon.ico", ordered) + print("icon.ico rebuilt with revised artwork.") + + +if __name__ == "__main__": + main() diff --git a/apps/desktop/src-tauri/icons/icon-128.png b/apps/desktop/src-tauri/icons/icon-128.png new file mode 100644 index 0000000..92b8de7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon-128.png differ diff --git a/apps/desktop/src-tauri/icons/icon-256.png b/apps/desktop/src-tauri/icons/icon-256.png new file mode 100644 index 0000000..f352481 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon-256.png differ diff --git a/apps/desktop/src-tauri/icons/icon-32.png b/apps/desktop/src-tauri/icons/icon-32.png new file mode 100644 index 0000000..cc872c2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon-32.png differ diff --git a/apps/desktop/src-tauri/icons/icon-512.png b/apps/desktop/src-tauri/icons/icon-512.png new file mode 100644 index 0000000..63202ef Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon-512.png differ diff --git a/apps/desktop/src-tauri/icons/icon-64.png b/apps/desktop/src-tauri/icons/icon-64.png new file mode 100644 index 0000000..cee1d09 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon-64.png differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index 454f644..ed0a29c 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index ab005ba..63202ef 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index b0679a6..ebed0bf 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -243,11 +243,17 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.") return } - if (!convexUserId || !comment.trim() || !ticket) return + if (!convexUserId || !ticket) return + const trimmed = comment.trim() + const hasText = trimmed.length > 0 + if (!hasText && attachments.length === 0) { + toast.error("Adicione uma mensagem ou anexe ao menos um arquivo antes de enviar.") + return + } const toastId = "portal-add-comment" toast.loading("Enviando comentário...", { id: toastId }) try { - const htmlBody = sanitizeEditorHtml(toHtmlFromText(comment.trim())) + const htmlBody = hasText ? sanitizeEditorHtml(toHtmlFromText(trimmed)) : "

" await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: convexUserId as Id<"users">,