Skip to content

Commit 95d27d9

Browse files
committed
fix: remove duplicate electron-drag bars causing chunky headers
- Remove electron-drag divs from sidebar, main content, bookmarks panel, and no-chat-selected state - Keep only the ROOT-level electron-drag bar which spans full width - This ensures consistent header heights between browser and Electron - Also includes: VCF parser for phone numbers, increased chat background opacity
1 parent bbe45bd commit 95d27d9

12 files changed

Lines changed: 517 additions & 107 deletions

File tree

messages/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@
7272
"stats_messages_by_participant": "Messages by Participant",
7373
"stats_activity_by_hour": "Activity by Hour",
7474

75+
"participants_title": "Participants",
76+
"participants_members": "{count} members",
77+
"participants_close": "Close participants",
78+
"participants_messages": "messages",
79+
"participants_phone_number": "Phone number",
80+
"participants_contact_name": "Contact name",
81+
"participants_phone_from_vcf": "Phone from shared contact",
82+
7583
"media_photo": "Photo",
7684
"media_video": "Video",
7785
"media_audio": "Voice Message",

messages/pt.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@
7272
"stats_messages_by_participant": "Mensagens por Participante",
7373
"stats_activity_by_hour": "Atividade por Hora",
7474

75+
"participants_title": "Participantes",
76+
"participants_members": "{count} membros",
77+
"participants_close": "Fechar participantes",
78+
"participants_messages": "mensagens",
79+
"participants_phone_number": "Número de telefone",
80+
"participants_contact_name": "Nome do contato",
81+
"participants_phone_from_vcf": "Telefone do contato compartilhado",
82+
7583
"media_photo": "Foto",
7684
"media_video": "Vídeo",
7785
"media_audio": "Mensagem de Voz",

src/app.css

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ body {
136136
.hamburger-btn {
137137
animation: hamburger-appear 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
138138
transition: box-shadow 0.3s ease, transform 0.2s ease;
139+
cursor: pointer;
139140
}
140141

141142
.hamburger-btn:hover {
@@ -242,3 +243,50 @@ body {
242243
pointer-events: none;
243244
}
244245
}
246+
247+
/* Bookmarks panel slide animation (mirrors sidebar) */
248+
.bookmarks-panel {
249+
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
250+
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
251+
opacity 0.25s ease,
252+
margin 0.3s cubic-bezier(0.4, 0, 0.2, 1);
253+
overflow: hidden;
254+
}
255+
256+
/* Desktop bookmarks collapse */
257+
@media (min-width: 768px) {
258+
.bookmarks-panel.bookmarks-open {
259+
width: 20rem; /* w-80 */
260+
}
261+
262+
.bookmarks-panel.bookmarks-closed {
263+
width: 0;
264+
margin-right: -1px; /* Hide border */
265+
opacity: 0;
266+
pointer-events: none;
267+
}
268+
}
269+
270+
/* Mobile bookmarks - slide overlay from right */
271+
@media (max-width: 767px) {
272+
.bookmarks-panel {
273+
transform: translateX(100%);
274+
opacity: 0;
275+
position: fixed;
276+
inset: 0 0 0 auto;
277+
z-index: 40;
278+
display: flex !important;
279+
width: 20rem; /* w-80 */
280+
}
281+
282+
.bookmarks-panel.bookmarks-open {
283+
transform: translateX(0);
284+
opacity: 1;
285+
}
286+
287+
.bookmarks-panel.bookmarks-closed {
288+
transform: translateX(100%);
289+
opacity: 0;
290+
pointer-events: none;
291+
}
292+
}

src/lib/components/BookmarksPanel.svelte

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -122,27 +122,29 @@ function handleKeydown(e: KeyboardEvent) {
122122
<svelte:window onkeydown={handleKeydown} />
123123

124124
<div class="flex flex-col h-full bg-white dark:bg-gray-800">
125-
<!-- Header -->
126-
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
127-
<div class="flex items-center gap-2">
128-
<svg class="w-5 h-5 text-[var(--color-whatsapp-teal)]" fill="currentColor" viewBox="0 0 24 24">
129-
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
130-
</svg>
131-
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Bookmarks</h2>
132-
<span class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full">
133-
{displayedBookmarks.length}
134-
</span>
125+
<!-- Header - matches Import Chat bar height (p-3 + h-10 inner) -->
126+
<div class="p-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
127+
<div class="flex items-center justify-between h-10">
128+
<div class="flex items-center gap-2">
129+
<svg class="w-5 h-5 text-[var(--color-whatsapp-teal)]" fill="currentColor" viewBox="0 0 24 24">
130+
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
131+
</svg>
132+
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Bookmarks</h2>
133+
<span class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full">
134+
{displayedBookmarks.length}
135+
</span>
136+
</div>
137+
<button
138+
type="button"
139+
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-700 transition-colors cursor-pointer"
140+
onclick={onClose}
141+
aria-label="Close bookmarks panel"
142+
>
143+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
144+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
145+
</svg>
146+
</button>
135147
</div>
136-
<button
137-
type="button"
138-
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-700 transition-colors cursor-pointer"
139-
onclick={onClose}
140-
aria-label="Close bookmarks panel"
141-
>
142-
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
143-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
144-
</svg>
145-
</button>
146148
</div>
147149

148150
<!-- Filter tabs -->

src/lib/components/ChatList.svelte

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { floating } from '$lib/actions/floating';
33
import type { ChatData } from '$lib/state.svelte';
44
import { getAvailableLanguages } from '$lib/transcription.svelte';
55
6+
interface LoadingChat {
7+
id: string;
8+
filename: string;
9+
progress: number;
10+
stage: 'reading' | 'extracting' | 'parsing';
11+
}
12+
613
interface Props {
714
chats: ChatData[];
815
selectedIndex: number | null;
@@ -12,6 +19,7 @@ interface Props {
1219
onLanguageChange?: (chatTitle: string, language: string) => void;
1320
autoLoadMediaByChat?: Map<string, boolean>;
1421
onAutoLoadMediaChange?: (chatTitle: string, enabled: boolean) => void;
22+
loadingChats?: LoadingChat[];
1523
}
1624
1725
let {
@@ -23,8 +31,15 @@ let {
2331
onLanguageChange,
2432
autoLoadMediaByChat = new Map(),
2533
onAutoLoadMediaChange,
34+
loadingChats = [],
2635
}: Props = $props();
2736
37+
const stageLabels = {
38+
reading: 'Reading file...',
39+
extracting: 'Extracting...',
40+
parsing: 'Parsing messages...',
41+
};
42+
2843
// Context menu state
2944
let contextMenuIndex = $state<number | null>(null);
3045
let menuButtonRef = $state<HTMLButtonElement | null>(null);
@@ -135,12 +150,49 @@ function getLastMessage(chat: ChatData): string {
135150
<div class="flex flex-col h-full bg-white dark:bg-gray-900">
136151
<!-- Chat list -->
137152
<div class="flex-1 overflow-y-auto">
138-
{#if chats.length === 0}
153+
{#if chats.length === 0 && loadingChats.length === 0}
139154
<div class="p-4 text-center text-gray-500 dark:text-gray-400">
140155
<p>No chats loaded</p>
141156
<p class="text-sm mt-1">Import a WhatsApp export to get started</p>
142157
</div>
143158
{:else}
159+
<!-- Loading chat placeholders -->
160+
{#each loadingChats as loadingChat (loadingChat.id)}
161+
<div
162+
class="w-full p-4 flex items-start gap-3 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50"
163+
>
164+
<!-- Animated avatar skeleton -->
165+
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 overflow-hidden">
166+
<svg class="w-6 h-6 text-gray-400 dark:text-gray-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
167+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
168+
</svg>
169+
</div>
170+
171+
<!-- Loading info -->
172+
<div class="flex-1 min-w-0 text-left">
173+
<div class="flex items-center justify-between">
174+
<h3 class="font-semibold text-gray-800 dark:text-white truncate">
175+
{loadingChat.filename}
176+
</h3>
177+
<span class="text-xs text-[var(--color-whatsapp-teal)] flex-shrink-0 ml-2">
178+
{Math.round(loadingChat.progress)}%
179+
</span>
180+
</div>
181+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
182+
{stageLabels[loadingChat.stage]}
183+
</p>
184+
<!-- Progress bar -->
185+
<div class="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
186+
<div
187+
class="h-full bg-[var(--color-whatsapp-teal)] rounded-full transition-all duration-300 ease-out"
188+
style="width: {loadingChat.progress}%"
189+
></div>
190+
</div>
191+
</div>
192+
</div>
193+
{/each}
194+
195+
<!-- Loaded chats -->
144196
{#each chats as chat, index}
145197
<div
146198
class="w-full p-4 flex items-start gap-3 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors border-b border-gray-100 dark:border-gray-800 cursor-pointer {selectedIndex === index ? 'bg-gray-100 dark:bg-gray-800' : ''}"

src/lib/components/LocaleSwitcher.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ function handleClickOutside() {
5454
<div class="locale-switcher relative">
5555
<button
5656
onclick={toggleDropdown}
57-
class="flex items-center justify-center text-xs font-medium uppercase transition-colors {variant === 'header'
58-
? 'p-1.5 rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white'
59-
: 'p-1.5 rounded bg-gray-100/80 dark:bg-gray-800/80 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 backdrop-blur-sm'}"
57+
class="flex items-center justify-center text-xs font-medium uppercase transition-colors p-1.5 rounded-full cursor-pointer {variant === 'header'
58+
? 'bg-white/10 hover:bg-white/20 text-white/80 hover:text-white'
59+
: 'bg-gray-100/80 dark:bg-gray-800/80 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 backdrop-blur-sm'}"
6060
aria-haspopup="listbox"
6161
aria-expanded={dropdownOpen}
6262
aria-label="Change language"
@@ -72,7 +72,7 @@ function handleClickOutside() {
7272
{#each locales as locale (locale)}
7373
<button
7474
onclick={(e) => { e.stopPropagation(); handleLocaleChange(locale); }}
75-
class="w-full px-3 py-1.5 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-between gap-2 transition-colors {locale === currentLocale ? 'text-[var(--color-whatsapp-teal)] font-medium' : 'text-gray-700 dark:text-gray-300'}"
75+
class="w-full px-3 py-1.5 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-between gap-2 transition-colors cursor-pointer {locale === currentLocale ? 'text-[var(--color-whatsapp-teal)] font-medium' : 'text-gray-700 dark:text-gray-300'}"
7676
role="option"
7777
aria-selected={locale === currentLocale}
7878
>

src/lib/parser/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ export {
1616
preloadMedia,
1717
readFileAsArrayBuffer,
1818
} from "./zip-parser";
19+
export type { ContactInfo } from "./vcf-parser";
20+
export { formatPhoneNumber, isPhoneNumber, parseVcf } from "./vcf-parser";

src/lib/parser/vcf-parser.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* VCF (vCard) Parser for WhatsApp contact cards
3+
*
4+
* Extracts contact information from .vcf files included in WhatsApp exports
5+
* These files contain name and phone number for contacts shared in chat
6+
*/
7+
8+
export interface ContactInfo {
9+
name: string;
10+
phoneNumber: string | null;
11+
whatsappId: string | null; // The waid from WhatsApp
12+
}
13+
14+
/**
15+
* Parse a single vCard file content
16+
*
17+
* Example VCF content:
18+
* BEGIN:VCARD
19+
* VERSION:3.0
20+
* N:Fontoura;Claudio;;;
21+
* FN:Claudio Fontoura
22+
* item1.TEL;waid=555191786084:+55 51 9178-6084
23+
* item1.X-ABLabel:Celular
24+
* END:VCARD
25+
*/
26+
export function parseVcf(content: string): ContactInfo | null {
27+
const lines = content.split(/\r?\n/);
28+
29+
let name: string | null = null;
30+
let phoneNumber: string | null = null;
31+
let whatsappId: string | null = null;
32+
33+
for (const line of lines) {
34+
// Extract formatted name (FN)
35+
if (line.startsWith('FN:')) {
36+
name = line.substring(3).trim();
37+
}
38+
39+
// Extract phone number and WhatsApp ID
40+
// Format: item1.TEL;waid=555192049865:+55 51 9204-9865
41+
// Or: TEL;TYPE=CELL:+55 51 9204-9865
42+
if (line.includes('TEL')) {
43+
// Try to extract waid (WhatsApp ID)
44+
const waidMatch = line.match(/waid=(\d+)/);
45+
if (waidMatch) {
46+
whatsappId = waidMatch[1];
47+
}
48+
49+
// Extract phone number (after the last colon)
50+
const colonIndex = line.lastIndexOf(':');
51+
if (colonIndex !== -1) {
52+
phoneNumber = line.substring(colonIndex + 1).trim();
53+
}
54+
}
55+
}
56+
57+
if (!name) {
58+
return null;
59+
}
60+
61+
return {
62+
name,
63+
phoneNumber,
64+
whatsappId,
65+
};
66+
}
67+
68+
/**
69+
* Format a phone number for display
70+
* Keeps the original formatting if it looks good, otherwise tries to clean it up
71+
*/
72+
export function formatPhoneNumber(phone: string): string {
73+
// Already well-formatted (has spaces or dashes)
74+
if (phone.includes(' ') || phone.includes('-')) {
75+
return phone;
76+
}
77+
78+
// Try to format Brazilian numbers (+55...)
79+
if (phone.startsWith('+55') && phone.length >= 13) {
80+
const digits = phone.replace(/\D/g, '');
81+
// Format: +55 XX XXXXX-XXXX
82+
if (digits.length === 13) {
83+
return `+${digits.slice(0, 2)} ${digits.slice(2, 4)} ${digits.slice(4, 9)}-${digits.slice(9)}`;
84+
}
85+
// Format: +55 XX XXXX-XXXX (older format)
86+
if (digits.length === 12) {
87+
return `+${digits.slice(0, 2)} ${digits.slice(2, 4)} ${digits.slice(4, 8)}-${digits.slice(8)}`;
88+
}
89+
}
90+
91+
// Return as-is if we can't format it
92+
return phone;
93+
}
94+
95+
/**
96+
* Check if a string looks like a phone number
97+
*/
98+
export function isPhoneNumber(str: string): boolean {
99+
// Remove common separators and check if mostly digits
100+
const cleaned = str.replace(/[\s\-\+\(\)]/g, '');
101+
// Should be at least 8 digits and mostly numeric
102+
return cleaned.length >= 8 && /^\d+$/.test(cleaned);
103+
}
104+
105+
/**
106+
* Extract phone number from WhatsApp ID format
107+
* WhatsApp IDs are usually just the phone number without formatting
108+
*/
109+
export function phoneFromWhatsAppId(waid: string): string {
110+
// Add + prefix if it's a raw number
111+
if (/^\d+$/.test(waid)) {
112+
return `+${waid}`;
113+
}
114+
return waid;
115+
}

0 commit comments

Comments
 (0)