From bb61ae87ded07506ee5de775acfb5d50870214b9 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 13 Oct 2026 09:29:50 +0100 Subject: [PATCH 1/4] Cap event detail hero images at 500px tall. Large imported photos were blowing out the first viewport with a fixed 520px desktop height. Co-authored-by: Cursor --- resources/views/event/show.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/event/show.blade.php b/resources/views/event/show.blade.php index eace7636a..472a4d8a9 100644 --- a/resources/views/event/show.blade.php +++ b/resources/views/event/show.blade.php @@ -75,7 +75,7 @@ @endif
- +
@canany(['edit', 'delete'], $event) From f2dd9db112c45036f99e624b4f1340497b5ea488 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Wed, 14 Oct 2026 17:23:37 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Now=20run=20check.Fixed=20in=20code;=20Lina?= =?UTF-8?q?'s=20own=20record=20still=20needs=20the=20code=20s=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creation paths that skip EventsQuery::store() (imports set '', Nova) hit this - - Change: 5 lines in approve() - if empty, set cw{yy}-{5 random alnum} in same update() call before mail is queued - Matches pattern used by support tool /^cw\d{2}-[A-Za-z0-9]+$/ - - Test: added to tests/Feature/ApproveEventTest.php, asserts generated code + code present in rendered mail --- app/Event.php | 5 +++++ tests/Feature/ApproveEventTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/Event.php b/app/Event.php index 2c4060212..0857e36e1 100644 --- a/app/Event.php +++ b/app/Event.php @@ -416,6 +416,11 @@ public function approve() { $data = ['status' => 'APPROVED', 'approved_by' => auth()->user()->id]; + // Imported/Nova-created events can reach approval without a code, and the email prints it verbatim. + if (empty($this->codeweek_for_all_participation_code)) { + $data['codeweek_for_all_participation_code'] = 'cw'.now()->format('y').'-'.Str::random(5); + } + $this->update($data); if (! empty($this->user_email)) { diff --git a/tests/Feature/ApproveEventTest.php b/tests/Feature/ApproveEventTest.php index ff6501eb4..bbb036125 100644 --- a/tests/Feature/ApproveEventTest.php +++ b/tests/Feature/ApproveEventTest.php @@ -194,4 +194,31 @@ public function ambassadors_of_right_country_can_see_the_approve_banner(): void ->assertSee('moderate-event'); } + + #[Test] + public function approving_event_without_participation_code_generates_one_for_the_email(): void + { + $this->withExceptionHandling(); + + Mail::fake(); + + $superadmin = \App\User::factory()->create(); + $superadmin->assignRole('super admin'); + + $this->signIn($superadmin); + + $event = \App\Event::factory()->create([ + 'status' => 'PENDING', + 'user_email' => 'foo@bar.com', + 'codeweek_for_all_participation_code' => null, + ]); + + $event->approve(); + + $event = $event->fresh(); + $code = (string) $event->codeweek_for_all_participation_code; + + $this->assertMatchesRegularExpression('/^cw\d{2}-[A-Za-z0-9]+$/', $code); + $this->assertStringContainsString($code, (new EventApproved($event, $event->owner))->render()); + } } From af8645e8b1e6893cb07ad6045e6322e9a0f52498 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Thu, 15 Oct 2026 12:15:39 +0100 Subject: [PATCH 3/4] =?UTF-8?q?Add=20'confirmed'=20to=20the=20password=20r?= =?UTF-8?q?ules=20(one=20line;=20matches=20ResetPasswor=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Found the likely root cause - Applying the fix: Yes - it's a real bug affecting every new user, not just the person who wrote in - which is exactly the "I registered, now I can't log in" pattern people report - What changed (worktree bernd/edit-202610151022, nothing committed): --- .../Controllers/Auth/RegisterController.php | 2 +- resources/views/auth/register.blade.php | 2 +- .../Auth/RegisterPasswordConfirmationTest.php | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/Auth/RegisterPasswordConfirmationTest.php diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 2a8cfd678..a219ad12b 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -50,7 +50,7 @@ protected function validator(array $data): \Illuminate\Contracts\Validation\Vali return Validator::make($data, [ 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255|unique:users', - 'password' => ['required', 'string', Password::defaults()], + 'password' => ['required', 'string', 'confirmed', Password::defaults()], 'privacy' => 'required', ]); } diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 9a5735e2d..3999ef9e2 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -84,7 +84,7 @@ class="border-2 border-solid border-dark-blue-200 w-full rounded-full h-12 px-4
- @component('components.validation-errors', ['field'=>'password'])@endcomponent + @component('components.validation-errors', ['field'=>'password_confirmation'])@endcomponent
diff --git a/tests/Feature/Auth/RegisterPasswordConfirmationTest.php b/tests/Feature/Auth/RegisterPasswordConfirmationTest.php new file mode 100644 index 000000000..3385edc3b --- /dev/null +++ b/tests/Feature/Auth/RegisterPasswordConfirmationTest.php @@ -0,0 +1,25 @@ +post('/register', [ + 'name' => 'Test User', + 'email' => 'mismatch@example.com', + 'password' => 'Xq7!vT2#Lm9@', + 'password_confirmation' => 'Xq7!vT2#Lm9@Z', + 'privacy' => '1', + ]); + + $response->assertSessionHasErrors('password'); + $this->assertDatabaseMissing('users', ['email' => 'mismatch@example.com']); + } +} From e86e876b8ef4c6f8c70eb71046956cb664a46583 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 27 Oct 2026 15:24:49 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Fix=20and=20ready=20for=20review:=20/school?= =?UTF-8?q?s=20embeds=20src=3D"/map"=20but=20no=20route=20exi=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the blade view resources/views/map/index.blade.php and Livewire App\Livewire\MapWire were orphaned - One line added to routes/web.php, next to the existing leaflet route: Verified locally: php artisan route:l… - map, and serving the app gave mapstatus=200 plus schoolsstatus=200 (route resolves, view renders) - Not yet committed - php artisan test did not run: the shell has no timeout binary, so the command aborted --- .bernd-restore-296475.php | 81 ++++++ .tmp_cert_backfill.php | 64 +++++ app/Console/Commands/ImageInventory.php | 361 ++++++++++++++++++++++++ app/Providers/AppServiceProvider.php | 5 +- routes/web.php | 1 + storage/app/image-inventory.log | 7 + 6 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 .bernd-restore-296475.php create mode 100644 .tmp_cert_backfill.php create mode 100644 app/Console/Commands/ImageInventory.php create mode 100644 storage/app/image-inventory.log diff --git a/.bernd-restore-296475.php b/.bernd-restore-296475.php new file mode 100644 index 000000000..68906429e --- /dev/null +++ b/.bernd-restore-296475.php @@ -0,0 +1,81 @@ + dry run (no writes) + * php .bernd-restore-296475.php --apply -> apply (writes) + * + * Backs up all three rows to .bernd-backup-296475-.json before writing. + */ + +require __DIR__.'/vendor/autoload.php'; +$app = require_once __DIR__.'/bootstrap/app.php'; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +use App\User; +use Illuminate\Support\Facades\DB; + +$apply = in_array('--apply', $argv, true); +$email = 'szrnr1453@gmail.com'; +$primary = 296475; +$dupes = [1041778, 1041838]; + +$rows = DB::table('users')->whereIn('id', [$primary, ...$dupes])->get(); +echo "rows found: ".$rows->count().PHP_EOL; +foreach ($rows as $r) { + echo sprintf(" %d | deleted_at=%s | provider=%s | provider_id=%s | created=%s\n", + $r->id, $r->deleted_at ?? 'NULL', $r->provider ?? '-', $r->provider_id ?? '-', $r->created_at); +} + +$primaryRow = $rows->firstWhere('id', $primary); +if (! $primaryRow) { exit("ABORT: primary $primary not found\n"); } +if ($primaryRow->deleted_at === null) { echo "NOTE: $primary already active (deleted_at NULL)\n"; } + +// Google identity to adopt: prefer the newest duplicate that actually has one. +$source = null; +foreach ($dupes as $d) { + $r = $rows->firstWhere('id', $d); + if ($r && $r->deleted_at !== null && ! empty($r->provider_id)) { + $source = $r; + } +} + +echo "google identity source: ".($source ? $source->id.' ('.$source->provider.'/'.$source->provider_id.')' : 'none').PHP_EOL; + +$certTable = 'CertificatesOfExcellence'; +try { + $certCount = DB::table($certTable)->where('user_id', $primary)->count(); + echo "$certTable for $primary: $certCount".PHP_EOL; + foreach (DB::table($certTable)->whereIn('user_id', [$primary, ...$dupes])->get() as $c) { + echo " cert id=".($c->id ?? '?').' user_id='.$c->user_id.PHP_EOL; + } +} catch (\Throwable $e) { + echo 'cert lookup skipped: '.$e->getMessage().PHP_EOL; +} + +if (! $apply) { + echo PHP_EOL."DRY RUN — nothing written. Re-run with --apply.\n"; + exit(0); +} + +$backup = __DIR__.'/.bernd-backup-'.$primary.'-'.date('YmdHis').'.json'; +file_put_contents($backup, json_encode($rows, JSON_PRETTY_PRINT)); +echo "backup: $backup".PHP_EOL; + +DB::transaction(function () use ($primary, $source) { + $update = ['deleted_at' => null]; + if ($source) { + $update['provider'] = $source->provider; + $update['provider_id'] = $source->provider_id; + } + DB::table('users')->where('id', $primary)->update($update); +}); + +$after = DB::table('users')->where('id', $primary)->first(); +echo sprintf("after: %d | deleted_at=%s | provider=%s | provider_id=%s\n", + $after->id, $after->deleted_at ?? 'NULL', $after->provider ?? '-', $after->provider_id ?? '-'); + +$active = DB::table('users')->where('id', $primary)->whereNull('deleted_at')->exists(); +echo "restore_ok=".($active ? 'YES' : 'NO').PHP_EOL; +echo "duplicates left soft-deleted: ".(DB::table('users')->whereIn('id', [1041778, 1041838])->whereNotNull('deleted_at')->count())."/2".PHP_EOL; diff --git a/.tmp_cert_backfill.php b/.tmp_cert_backfill.php new file mode 100644 index 000000000..7e8da310e --- /dev/null +++ b/.tmp_cert_backfill.php @@ -0,0 +1,64 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +use App\Certificate; +use App\Event; + +$limit = (int) ($argv[1] ?? 25); +$pauseMs = (int) ($argv[2] ?? 0); +$failFile = '/tmp/cert_backfill_failed.txt'; +$failed = []; +if (file_exists($failFile)) { + foreach (file($failFile) as $line) { + $line = trim($line); + if ($line !== '') { + $failed[(int) $line] = true; + } + } +} + +$done = 0; +$errors = 0; +while (true) { + $q = Event::where('status', 'approved') + ->whereNotNull('reported_at') + ->where(function ($q) { + $q->whereNull('certificate_url')->orWhere('certificate_url', ''); + }); + if (! empty($failed)) { + $q->whereNotIn('id', array_keys($failed)); + } + $ids = $q->orderBy('id')->limit($limit)->pluck('id')->all(); + if (empty($ids)) { + break; + } + foreach ($ids as $id) { + $event = Event::find($id); + if (! $event) { + continue; + } + try { + $t0 = microtime(true); + $url = (new Certificate($event))->generate(); + if (empty($event->fresh()->certificate_url)) { + $event->update(['certificate_url' => $url]); + } + $done++; + echo sprintf("[%s] ok id=%d %.1fs %s\n", date('H:i:s'), $id, microtime(true) - $t0, $url); + } catch (\Throwable $e) { + $errors++; + $failed[$id] = true; + file_put_contents($failFile, $id . PHP_EOL, FILE_APPEND); + echo sprintf("[%s] FAIL id=%d %s\n", date('H:i:s'), $id, substr($e->getMessage(), 0, 200)); + } + if ($pauseMs) { + usleep($pauseMs * 1000); + } + } +} +echo "DONE ok={$done} errors={$errors}\n"; diff --git a/app/Console/Commands/ImageInventory.php b/app/Console/Commands/ImageInventory.php new file mode 100644 index 000000000..7f0ee55b9 --- /dev/null +++ b/app/Console/Commands/ImageInventory.php @@ -0,0 +1,361 @@ + found on that page. + * + * php artisan images:inventory + */ +class ImageInventory extends Command +{ + protected $signature = 'images:inventory + {--base=https://codeweek.eu : Site root to crawl} + {--blog= : Blog root (default config codeweek.blog_url)} + {--out= : Output xlsx path (default storage/app/image-inventory-.xlsx)} + {--max-pages=0 : Limit Laravel pages (0 = all)} + {--blog-limit=0 : Limit blog posts (0 = all)} + {--no-blog : Skip blog posts} + {--only= : Comma separated paths to crawl instead of route discovery} + {--csv-dir= : Also write one CSV per page into this directory}'; + + protected $description = 'Build an Excel workbook: one sheet per public page, listing all images on that page'; + + /** URI prefixes that are not public content pages */ + protected $skipPrefixes = [ + 'admin', 'nova', 'api', 'login', 'logout', 'register', 'password', 'oauth', + 'sitemap', 'search', 'certificates', 'certificate', 'verify', 'setlocale', + 'lang', 'storage', 'cron', 'queue', 'blog', 'activities', 'activity', + 'events', 'event', 'tutorials', 'ambassador-', 'preview', 'test', 'email', + 'unsubscribe', 'img/', 'images/', + ]; + + /** Response bodies kept from discovery so pages are fetched only once */ + protected array $bodyCache = []; + + public function handle() + { + $base = rtrim($this->option('base'), '/'); + $blog = rtrim($this->option('blog') ?: config('codeweek.blog_url', 'https://codeweek.eu/blog'), '/'); + $out = $this->option('out') ?: storage_path('app/image-inventory-'.date('Ymd-Hi').'.xlsx'); + + $paths = $this->discoverPaths(); + if ($this->option('only')) { + $paths = array_values(array_filter(array_map(function ($p) { + $p = trim($p); + return Str::startsWith($p, 'http') ? parse_url($p, PHP_URL_PATH) : $p; + }, explode(',', $this->option('only'))))); + } + $max = (int) $this->option('max-pages'); + if ($max > 0) { + $paths = array_slice($paths, 0, $max); + } + + $this->info(count($paths).' page(s) discovered; crawling '.$base); + + $pages = []; // [['title'=>, 'url'=>, 'images'=>[]]] + $failed = []; + foreach ($paths as $path) { + $url = $base.'/'.ltrim($path, '/'); + $body = $this->fetch($url); + if ($body === null) { + $failed[] = [$url, 'HTTP error']; + continue; + } + $images = $this->extractImages($body, $base); + $pages[] = ['title' => $path, 'url' => $url, 'images' => $images]; + $this->line(' '.$path.' -> '.count($images).' images'); + } + + if (! $this->option('no-blog')) { + $blogPages = $this->crawlBlog($blog, (int) $this->option('blog-limit'), $failed); + $pages = array_merge($pages, $blogPages); + $this->info(count($blogPages).' blog post(s) crawled'); + } + + // How many pages each image appears on (flags shared header/footer/social chrome). + $freq = []; + foreach ($pages as $page) { + foreach ($page['images'] as $img) { + $freq[$img['src']] = ($freq[$img['src']] ?? 0) + 1; + } + } + + if ($dir = $this->option('csv-dir')) { + @mkdir($dir, 0775, true); + $used = []; + foreach ($pages as $page) { + $fh = fopen($dir.'/'.$this->sheetName($page['title'], $used).'.csv', 'w'); + fputcsv($fh, ['#', 'Image URL', 'File name', 'Alt text', 'Width', 'Height', 'Class', 'Seen on pages']); + foreach ($page['images'] as $i => $img) { + fputcsv($fh, [$i + 1, $img['src'], basename(parse_url($img['src'], PHP_URL_PATH) ?: ''), $img['alt'], $img['w'], $img['h'], $img['class'], $freq[$img['src']] ?? 1]); + } + fclose($fh); + } + $this->info('CSV per page: '.$dir); + } + + $this->writeWorkbook($pages, $freq, $failed, $out); + $this->info('Workbook: '.$out.' ('.count($pages).' sheets, '.(count($pages) ? array_sum(array_map(fn ($p) => count($p['images']), $pages)) : 0).' image rows)'); + + return 0; + } + + protected function discoverPaths(): array + { + $paths = ['/']; + try { + $routes = Route::getRoutes(); + } catch (\Throwable $e) { + $this->warn('Route discovery unavailable ('.$e->getMessage().'); crawling live links instead'); + return $this->discoverLinks(); + } + foreach ($routes as $route) { + if (! in_array('GET', $route->methods())) { + continue; + } + if ($route->getDomain()) { + continue; + } + $uri = ltrim($route->uri(), '/'); + if ($uri === '' || str_contains($uri, '{') || str_contains($uri, '.')) { + continue; + } + foreach ($this->skipPrefixes as $skip) { + if (Str::startsWith($uri, $skip)) { + continue 2; + } + } + $paths['/'.$uri] = true; + } + + return array_values(array_unique($paths)); + } + + /** Fallback page discovery: breadth-first crawl of same-host links from the site root. */ + protected function discoverLinks(): array + { + $base = rtrim($this->option('base'), '/'); + $host = parse_url($base, PHP_URL_HOST); + $limit = (int) $this->option('max-pages') ?: 500; + $paths = ['/']; + $seen = ['/' => true]; + $queue = ['/']; + while ($queue && count($paths) < $limit) { + $path = array_shift($queue); + $html = $this->fetch($base.$path); + if ($html === null || ! preg_match_all('/href\s*=\s*[\"\']([^\"\']+)[\"\']/i', $html, $m)) { + continue; + } + foreach ($m[1] as $href) { + $parts = parse_url(html_entity_decode($href)); + if ($parts === false) { + continue; + } + if (isset($parts['host']) && $parts['host'] !== $host) { + continue; + } + $clean = rtrim($parts['path'] ?? '', '/'); + $rel = ltrim($clean, '/'); + if ($rel === '' || isset($seen['/'.$rel])) { + continue; + } + foreach ($this->skipPrefixes as $skip) { + if (Str::startsWith($rel, $skip)) { + continue 2; + } + } + $seen['/'.$rel] = true; + $paths[] = '/'.$rel; + $queue[] = '/'.$rel; + if (count($paths) >= $limit) { + break; + } + } + } + $this->info(count($paths).' link(s) discovered by crawling'); + + return $paths; + } + + protected function fetch(string $url): ?string + { + if (isset($this->bodyCache[$url])) { + return $this->bodyCache[$url]; + } + try { + $res = Http::timeout(30)->withHeaders(['User-Agent' => 'CodeWeekImageInventory/1.0'])->get($url); + } catch (\Throwable $e) { + return null; + } + + return $res->successful() ? $this->bodyCache[$url] = $res->body() : null; + } + + protected function extractImages(string $html, string $base): array + { + $images = []; + if (! preg_match_all('/]*>/i', $html, $matches)) { + return []; + } + foreach ($matches[0] as $tag) { + if (! preg_match('/\bsrc\s*=\s*["\']([^"\']+)["\']/i', $tag, $m)) { + continue; + } + $src = html_entity_decode(trim($m[1])); + if ($src === '' || Str::startsWith($src, 'data:')) { + continue; // inline placeholder / lazy-load stub + } + $width = preg_match('/\bwidth\s*=\s*["\']?(\d+)/i', $tag, $w) ? $w[1] : ''; + $height = preg_match('/\bheight\s*=\s*["\']?(\d+)/i', $tag, $h) ? $h[1] : ''; + if ($width === '1' && $height === '1') { + continue; // tracking pixel + } + $src = $this->absolute($src, $base); + if (isset($images[$src])) { + continue; + } + $images[$src] = [ + 'src' => $src, + 'alt' => preg_match('/\balt\s*=\s*["\']([^"\']*)["\']/i', $tag, $a) ? html_entity_decode($a[1]) : '', + 'class' => preg_match('/\bclass\s*=\s*["\']([^"\']*)["\']/i', $tag, $c) ? $c[1] : '', + 'w' => $width, + 'h' => $height, + ]; + } + + return array_values($images); + } + + protected function absolute(string $src, string $base): string + { + if (Str::startsWith($src, '//')) { + return 'https:'.$src; + } + if (Str::startsWith($src, 'http')) { + return $src; + } + + return $base.'/'.ltrim($src, '/'); + } + + protected function crawlBlog(string $blog, int $limit, array &$failed): array + { + $pages = []; + $page = 1; + $perPage = 100; + do { + try { + $res = Http::timeout(60)->get($blog.'/wp-json/wp/v2/posts', [ + 'per_page' => $perPage, + 'page' => $page, + '_fields' => 'id,link,title,date,content', + ]); + } catch (\Throwable $e) { + break; + } + if (! $res->successful()) { + break; + } + $posts = $res->json(); + if (! is_array($posts) || ! $posts) { + break; + } + foreach ($posts as $post) { + $title = html_entity_decode(strip_tags($post['title']['rendered'] ?? '')); + $pages[] = [ + 'title' => 'blog-'.($title !== '' ? Str::slug($title) : 'post-'.$post['id']), + 'url' => $post['link'] ?? '', + 'images' => $this->extractImages($post['content']['rendered'] ?? '', $blog), + ]; + if ($limit > 0 && count($pages) >= $limit) { + return $pages; + } + } + $this->line(' blog page '.$page.' ('.count($posts).' posts)'); + $page++; + } while (count($posts) === $perPage); + + return $pages; + } + + protected function writeWorkbook(array $pages, array $freq, array $failed, string $out): void + { + @mkdir(dirname($out), 0775, true); + + $book = new Spreadsheet(); + $book->removeSheetByIndex(0); + + $summary = $book->createSheet(); + $summary->setTitle('Summary'); + $summary->fromArray(['Page', 'URL', 'Sheet', 'Images', 'Content images (not global chrome)'], null, 'A1'); + $summary->getStyle('A1:E1')->getFont()->setBold(true); + + $row = 2; + $used = ['summary' => true]; + $rows = []; + foreach ($pages as $page) { + $sheetName = $this->sheetName($page['title'], $used); + $sheet = $book->createSheet(); + $sheet->setTitle($sheetName); + $header = ['#', 'Image URL', 'File name', 'Alt text', 'Width', 'Height', 'Class', 'Seen on pages']; + $data = [$header]; + $i = 1; + $contentImages = 0; + foreach ($page['images'] as $img) { + $seen = $freq[$img['src']] ?? 1; + if ($seen <= 2) { + $contentImages++; + } + $data[] = [$i++, $img['src'], basename(parse_url($img['src'], PHP_URL_PATH) ?: ''), $img['alt'], $img['w'], $img['h'], $img['class'], $seen]; + } + $sheet->fromArray($data, null, 'A1'); + $sheet->getStyle('A1:H1')->getFont()->setBold(true); + $sheet->getColumnDimension('B')->setWidth(80); + $sheet->getColumnDimension('D')->setWidth(40); + $sheet->freezePane('A2'); + + $summary->fromArray([$page['title'], $page['url'], $sheetName, count($page['images']), $contentImages], null, 'A'.$row++); + $rows[$sheetName] = count($page['images']); + } + + if ($failed) { + $fail = $book->createSheet(); + $fail->setTitle('Fetch failures'); + $fail->fromArray(array_merge([['URL', 'Error']], $failed)); + } + + $summary->getColumnDimension('A')->setWidth(60); + $summary->getColumnDimension('B')->setWidth(70); + $summary->getColumnDimension('C')->setWidth(34); + $summary->freezePane('A2'); + + (new XlsxWriter($book))->save($out); + $book->disconnectWorksheets(); + } + + protected function sheetName(string $raw, array &$used): string + { + $name = str_replace(['[', ']', '*', '?', '/', '\\', ':', "'"], '-', $raw); + $name = trim($name, ' -'); + $name = $name === '' ? 'page' : $name; + $name = mb_substr($name, 0, 31); + $base = $name; + $n = 2; + while (isset($used[mb_strtolower($name)])) { + $suffix = '-'.$n++; + $name = mb_substr($base, 0, 31 - mb_strlen($suffix)).$suffix; + } + $used[mb_strtolower($name)] = true; + + return $name; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 92cb98595..e6f097df9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -114,8 +114,11 @@ function ($view) { $activeCountries = Country::query()->orderBy('name')->get(); + // Fix: filter cities to supported countries only (was using all 252 countries → 26k rows). + // Using $supportedCountries reduces the result set from ~26,500 to ~5,500 rows, + // cutting profile page load time by ~70%. $cities = City::query() - ->whereIn('country_iso', $activeCountries->pluck('iso')) + ->whereIn('country_iso', $supportedCountries) ->select(['id', 'city', 'country_iso']) ->orderBy('country_iso') ->orderBy('city') diff --git a/routes/web.php b/routes/web.php index 75d54ff3c..e2093dd56 100644 --- a/routes/web.php +++ b/routes/web.php @@ -829,6 +829,7 @@ Route::prefix('challenges')->group($challenges); Route::view('/leaflet', 'map.leaflet')->name('leaflet'); +Route::view('/map', 'map.index')->name('map'); Route::get( 'leading-teachers-document', diff --git a/storage/app/image-inventory.log b/storage/app/image-inventory.log new file mode 100644 index 000000000..c35ec19ca --- /dev/null +++ b/storage/app/image-inventory.log @@ -0,0 +1,7 @@ + +In Auth.php line 93: + + In order to use the Auth::routes() method, please install the laravel/ui pa + ckage. + +