Wall of People Groups: the store no longer downloads every photo in the background, and the dead source picker and old card are gone

Measured on the live list (2026-09-29): after loading, the store tested every photo by loading it, three at a time with a second between batches: 932 photos, 132 MB by their Content-Length (the 77 IMB photos alone are 115 MB), for as long as the page stayed open, whether or not anyone zoomed in. All 939 photo URLs answered 200, and the canvas already loads only the photos in view and draws its grey box for any that fail, so the test is removed. A headless run of the real store with a counting Image stub: 12 photo requests in the first 3.5 s before, 0 after.

A group with no photo URL now gets none, instead of the stock "no image" photo, which answers 404. Today every group has a URL (a regional photo when it has none of its own), so nothing changes on screen.

The source picker only showed with two sources or more, and there has been one since the WordPress API and the CSV were dropped. It goes, with the two loaders, the CSV parser, their CSS, and the card component nothing imports. The bundle is 9.6 kB smaller (2.5 kB gzip).
This commit is contained in:
Doxa Maps 2026-09-29 06:00:01 +00:00
parent a00c9af009
commit 32e3a20ae4
5 changed files with 17 additions and 799 deletions

View file

@ -1,150 +0,0 @@
<template>
<div
class="people-group-card"
:style="{ transform: 'translate(' + x + 'px, ' + y + 'px)' }"
>
<!-- Header with People Group Name -->
<div class="card-header">
<h3 class="group-name">
<a
v-if="hasJoshuaProjectLink()"
:href="getJoshuaProjectUrl()"
target="_blank"
rel="noopener noreferrer"
class="group-name-link"
>
{{ group.name || group.post_title || $t('wall.unknownGroup') }}
</a>
<span v-else class="group-name-no-link" :title="$t('wall.noPeopleId')">
{{ group.name || group.post_title || $t('wall.unknownGroup') }}
</span>
</h3>
<span class="group-status">{{ group.status || $t('wall.statusUnknown') }}</span>
</div>
<!-- Image Placeholder -->
<div class="card-image" @click="$emit('view-detail', group)">
<img
:src="getImageUrl()"
:alt="group.name"
@error="handleImageError"
/>
</div>
<!-- Action Buttons (Instagram style) -->
<div class="card-actions">
<button
class="action-btn heart-btn"
@click="handlePrayer"
:class="{ active: hasPrayed }"
>
<span class="icon">{{ hasPrayed ? '❤️' : '🤍' }}</span>
</button>
<button
class="action-btn comment-btn"
@click="$emit('open-comments', group)"
>
<span class="icon">💬</span>
</button>
<button
class="action-btn share-btn"
@click="handleShare"
>
<span class="icon">📤</span>
</button>
</div>
<!-- Prayer Count -->
<div class="card-footer">
<p class="prayer-count">
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person' : 'people' }}
prayed that they would receive God's love and the love of a missionary
</p>
<button
class="view-comments-btn"
@click="$emit('open-comments', group)"
>
View prayers and comments
</button>
</div>
</div>
</template>
<script>
// No third-party placeholder service: a missing photo is drawn inline.
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
// People Group Card Component - Instagram style
export default {
name: 'PeopleGroupCard',
props: {
group: {
type: Object,
required: true
},
x: {
type: Number,
default: 0
},
y: {
type: Number,
default: 0
}
},
data() {
return {
imageError: false,
hasPrayed: false
};
},
computed: {
prayerCount() {
// Access the prayers state directly
const groupId = this.group.id || this.group.PeopleID3;
return this.$store?.prayers?.[groupId] || 0;
}
},
methods: {
hasJoshuaProjectLink() {
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
return peopleID3 && peopleID3.trim() !== '';
},
getJoshuaProjectUrl() {
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
if (!peopleID3 || peopleID3.trim() === '') {
return null;
}
return `https://joshuaproject.net/people_groups/${peopleID3}`;
},
getImageUrl() {
if (this.imageError) {
return NO_IMAGE;
}
// The live API's image_url; uupg.doxa.life no longer answers.
return this.group.imageUrl || NO_IMAGE;
},
handleImageError(event) {
this.imageError = true;
event.target.src = NO_IMAGE;
},
handlePrayer() {
this.hasPrayed = !this.hasPrayed;
if (this.hasPrayed) {
this.$emit('add-prayer', this.group.id || this.group.PeopleID3);
}
},
handleShare() {
alert('Sharing functionality coming later!');
}
}
};
</script>

View file

@ -1,84 +0,0 @@
<template>
<!-- availableSources, the computed below: the template read an undefined `sources`, so this
threw "reading 'length'" on EVERY render, online or not (Vue logs it; nothing crashes) -->
<div v-if="availableSources.length > 1">
<div class="source-selector">
<div class="source-header">
<h3>📊 Data Source</h3>
<span class="current-source-label">Current: {{ currentSource?.name }}</span>
</div>
<div class="source-buttons">
<button
v-for="source in availableSources"
:key="source.id"
@click="selectSource(source.id)"
:class="['source-btn', { active: dataSource === source.id }]"
>
<span class="source-icon">{{ getSourceIcon(source.id) }}</span>
<span class="source-name">{{ source.name }}</span>
<span v-if="dataSource === source.id" class="active-indicator">✓</span>
</button>
</div>
<div class="source-info">
<p v-if="currentSource">
<strong>Source URL:</strong><br>
<code>{{ currentSource.url }}</code>
</p>
<p v-if="dataSource === 'CSV'" class="csv-info">
<span aria-hidden="true">ⓘ</span> CSV includes ~1,439 groups with images from Joshua Project
</p>
<p v-if="dataSource === 'DOXA_API'" class="api-info">
<span aria-hidden="true">ⓘ</span> WordPress API — descriptions & photos, no prayer data
</p>
<p v-if="dataSource === 'PRAY_API'" class="api-info">
<span aria-hidden="true">ⓘ</span> Live prayer data — people praying, committed workers, adoption, images
</p>
</div>
</div>
</div>
</template>
<script>
// Source Selector Component - Switch between API and CSV data sources
export default {
name: 'SourceSelector',
computed: {
dataSource() {
return this.$store?.dataSource || 'API';
},
availableSources() {
return this.$store?.availableSources || [];
},
currentSource() {
return this.$store?.currentSource || null;
}
},
methods: {
selectSource(sourceId) {
if (this.$store) {
this.$store.setDataSource(sourceId);
}
},
getSourceIcon(sourceId) {
switch(sourceId) {
case 'PRAY_API':
return '🙏';
case 'DOXA_API':
return '🌐';
case 'API':
return '🌐';
case 'CSV':
return '📄';
default:
return '📊';
}
}
}
};
</script>

View file

@ -17,9 +17,10 @@ export const usePeopleGroupsStore = (pinia) => {
error: null,
prayers: {}, // Track prayers per people group ID
comments: {}, // Track comments per people group ID
dataSource: 'PRAY_API', // 'PRAY_API', 'DOXA_API', or 'CSV'
// The Doxa WordPress endpoint (uupg.doxa.life) no longer answers, and the CSV fallback was a
// 2.6 MB copy of what the live API already serves, so neither ships.
dataSource: 'PRAY_API',
// The one source. The Doxa WordPress endpoint (uupg.doxa.life) no longer answers, and the CSV
// fallback was a 2.6 MB copy of what the live API already serves, so neither ships, and with
// them went the source picker (it only showed with two sources or more) and their loaders.
availableSources: [
{ id: 'PRAY_API', name: 'Prayer.Tools API (Live)', url: 'https://pray.doxa.life/api/people-groups/list', description: 'Live prayer data with images' }
]
@ -34,10 +35,6 @@ export const usePeopleGroupsStore = (pinia) => {
getComments: (state) => (groupId) => {
return state.comments[groupId] || [];
},
currentSource: (state) => {
return state.availableSources.find(s => s.id === state.dataSource);
}
},
@ -48,15 +45,7 @@ export const usePeopleGroupsStore = (pinia) => {
try {
if (this.dataSource === 'PRAY_API') {
await this.fetchFromPrayAPI();
} else if (this.dataSource === 'DOXA_API') {
await this.fetchFromAPI();
} else if (this.dataSource === 'CSV') {
await this.fetchFromCSV();
}
await this.fetchFromPrayAPI();
} catch (error) {
// warn, not error: the wall now SAYS so on screen (PeopleGroupsWall's offline notice)
// and offers a retry; an unreachable API is an expected state, not a crash.
@ -94,9 +83,6 @@ export const usePeopleGroupsStore = (pinia) => {
return;
}
// Placeholder for groups without photos
const noPhotoPlaceholder = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
this.peopleGroups = posts.map(group => {
// Extract labels from nested value/label objects
const regionLabel = group.wagf_region?.label || group.wagf_region || '';
@ -107,9 +93,12 @@ export const usePeopleGroupsStore = (pinia) => {
const religionCode = group.religion?.value || '';
const affinityLabel = group.rop1?.label || '';
// Image URL — API provides direct URLs, with regional fallbacks for no-photo groups
// The API's own photo URL, or none. Today every group has one (a group without its own
// photo gets a regional one). The stock "no image" photo this used to fall back to answers
// 404 (2026-09-29), so a group with no URL now gets none: the canvas draws its grey box
// without a request, and the print its .noimg box, the same on both.
const hasPhoto = group.has_photo === true || group.has_photo === 'true' || group.has_photo === 1;
const imageUrl = group.image_url || noPhotoPlaceholder;
const imageUrl = group.image_url || '';
return {
// Standard fields
@ -139,421 +128,16 @@ export const usePeopleGroupsStore = (pinia) => {
imageUrl: imageUrl,
hasPhoto: hasPhoto,
photoCredit: group.picture_credit || null,
imageStatus: hasPhoto ? 'pending' : 'placeholder',
// Keep all original API data
...group
};
});
// Log prayer & engagement statistics
const withPrayer = this.peopleGroups.filter(g => g.peoplePraying > 0).length;
const withCommitted = this.peopleGroups.filter(g => g.peopleCommitted > 0).length;
const withAdoption = this.peopleGroups.filter(g => g.adoptedByChurches > 0).length;
const withImages = this.peopleGroups.filter(g => g.hasPhoto).length;
// Start background image testing
this.testImagesInBackground();
},
async fetchFromAPI() {
const source = this.availableSources.find(s => s.id === 'DOXA_API');
const apiUrl = source.url;
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Guard against HTML responses (bot protection, error pages)
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html')) {
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
}
const data = await response.json();
// Placeholder image for groups without photos
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
// The API returns { "posts": [...] }
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []), this.profileConfig);
if (posts.length === 0) {
console.error('Unexpected API response format:', data);
this.peopleGroups = [];
return;
}
this.peopleGroups = posts.map(group => {
// Determine image URL from API data
const hasPhoto = group.imb_has_photo === '1' || group.imb_has_photo === 1 || group.imb_has_photo === true;
const apiImageUrl = group.imb_picture_url || null;
const imageUrl = (hasPhoto && apiImageUrl) ? apiImageUrl : placeholderUrl;
return {
// Standard fields mapped from API
ID: group.dt_id || group.ID || `api-${Math.random().toString(36).slice(2)}`,
post_title: group.imb_display_name || group.post_title || 'Unknown',
name: group.imb_display_name || group.post_title || 'Unknown',
slug: group.slug || '',
status: group.status || '',
country: group.imb_isoalpha3 || '',
region: group.doxa_wagf_region || '',
wagfBlock: group.doxa_wagf_block || '',
population: group.imb_population || '',
religion: group.imb_reg_of_religion || '',
religionCode: group.imb_reg_of_religion_3 || '',
latitude: group.imb_lat || null,
longitude: group.imb_lng || null,
description: group.imb_people_description || '',
// Image fields
imageUrl: imageUrl,
hasPhoto: hasPhoto,
photoCredit: group.imb_picture_credit_html || '',
imageStatus: hasPhoto && apiImageUrl ? 'pending' : 'placeholder',
// Keep all original API data
...group
};
});
// Log image statistics
const withImages = this.peopleGroups.filter(g => g.hasPhoto && g.imb_picture_url).length;
const withPlaceholder = this.peopleGroups.length - withImages;
// Start background image testing for groups that have URLs
this.testImagesInBackground();
},
async fetchFromCSV() {
const source = this.availableSources.find(s => s.id === 'CSV');
const csvUrl = source.url;
const response = await fetch(csvUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const csvText = await response.text();
const parsedData = this.parseCSV(csvText);
// Map all groups to standard format
const allGroups = parsedData.map(group => ({
ID: group.PEID || group.PeopleID3 || `csv-${Math.random()}`,
post_title: group.Name || group.PplNm,
name: group.Name || group.PplNm,
status: group.Status || 'Active',
country: group.Ctry,
region: group.Regn,
population: group.Pop,
language: group.Lang,
religion: group.Rlgn,
peopleID3: group.PeopleID3 && group.PeopleID3.trim() !== '' ? group.PeopleID3.trim() : null,
imageUrl: group.PeopleID3 && group.PeopleID3.trim() !== '' ? this.generateImageUrl(group.PeopleID3.trim()) : null,
imageStatus: 'pending', // 'pending', 'loading', 'loaded', 'failed', 'error'
// Include all original CSV data
...group
}));
// Count cluster statistics
const clusterStats = {};
allGroups.forEach(group => {
if (group.peopleID3) {
if (!clusterStats[group.peopleID3]) {
clusterStats[group.peopleID3] = { total: 0 };
}
clusterStats[group.peopleID3].total++;
}
});
// Initial sort by cluster size
this.peopleGroups = allGroups.sort((a, b) => {
const aHasID = a.peopleID3 ? 1 : 0;
const bHasID = b.peopleID3 ? 1 : 0;
// Groups with PeopleID3 come before groups without
if (aHasID !== bHasID) {
return bHasID - aHasID;
}
// Both have PeopleID3: sort by cluster size
if (aHasID && bHasID) {
const aCluster = clusterStats[a.peopleID3];
const bCluster = clusterStats[b.peopleID3];
if (aCluster.total !== bCluster.total) {
return bCluster.total - aCluster.total;
}
return a.peopleID3.localeCompare(b.peopleID3);
}
return 0;
});
const withPeopleID3 = this.peopleGroups.filter(g => g.peopleID3).length;
const withoutPeopleID3 = this.peopleGroups.length - withPeopleID3;
const uniqueClusters = Object.keys(clusterStats).length;
// Start background image testing (non-blocking)
this.testImagesInBackground();
},
async testImagesInBackground() {
// Get groups that need image testing (have a real imageUrl, not placeholder)
const groupsToTest = this.peopleGroups.filter(g =>
g.imageUrl && g.imageStatus === 'pending'
);
// Deduplicate by imageUrl
const uniqueTests = new Map();
groupsToTest.forEach(group => {
if (!uniqueTests.has(group.imageUrl)) {
uniqueTests.set(group.imageUrl, {
url: group.imageUrl,
groupIds: [group.ID],
status: 'pending'
});
} else {
uniqueTests.get(group.imageUrl).groupIds.push(group.ID);
}
});
const totalToTest = uniqueTests.size;
let tested = 0;
let loaded = 0;
let failed = 0;
let errors = 0;
// Test images SLOWLY - only 3 at a time with delays
const batchSize = 3; // Small batch size to avoid rate limiting
const delayBetweenBatches = 1000; // 1 second delay between batches
const maxRetries = 3; // Retry failed images up to 3 times
const entries = Array.from(uniqueTests.entries());
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
for (let i = 0; i < entries.length; i += batchSize) {
const batch = entries.slice(i, i + batchSize);
const promises = batch.map(([imageUrl, info]) =>
this.testImageWithRetry(info.url, null, maxRetries).then(result => ({
imageUrl,
groupIds: info.groupIds,
...result
}))
);
const results = await Promise.all(promises);
// Update groups with results
results.forEach(result => {
const { groupIds, status, attempts } = result;
this.peopleGroups.forEach(group => {
if (groupIds.includes(group.ID)) {
group.imageStatus = status;
group.imageAttempts = attempts;
if (status === 'failed' || status === 'error') {
// Fall back to placeholder
group.imageUrl = placeholderUrl;
group.imageStatus = 'placeholder';
}
}
});
tested++;
if (status === 'loaded') {
loaded++;
} else if (status === 'failed') {
failed++;
console.warn(` ❌ 404: ${result.imageUrl} (tried ${attempts} times)`);
} else if (status === 'error') {
errors++;
console.error(` ⚠️ Error: ${result.imageUrl} (tried ${attempts} times)`);
}
});
// Log progress every 10 batches
if ((i / batchSize) % 10 === 0) {
}
// Delay between batches to avoid rate limiting
if (i + batchSize < entries.length) {
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
}
}
},
async testImageWithRetry(url, peopleID3, maxRetries = 3) {
let lastResult = null;
// First, try the primary URL (media.joshuaproject.net with /public/)
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const result = await this.testImageLoadDetailed(url);
// If loaded successfully, return immediately
if (result.status === 'loaded') {
return { ...result, attempts: attempt, urlFormat: 'primary' };
}
lastResult = result;
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
const delay = 500 * attempt; // 500ms, 1000ms, 1500ms...
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Primary URL failed, try fallback URL format
const fallbackUrl = `https://joshuaproject.net/assets/media/profiles/photos/p${peopleID3}.jpg`;
const fallbackResult = await this.testImageLoadDetailed(fallbackUrl);
if (fallbackResult.status === 'loaded') {
// Update the group's imageUrl to use the fallback
this.peopleGroups.forEach(group => {
if (group.peopleID3 === peopleID3) {
group.imageUrl = fallbackUrl;
}
});
return { ...fallbackResult, attempts: maxRetries + 1, urlFormat: 'fallback' };
}
// Both primary and fallback failed
return { ...lastResult, attempts: maxRetries + 1, urlFormat: 'none' };
},
testImageLoadDetailed(url) {
return new Promise((resolve) => {
const img = new Image();
const timeout = setTimeout(() => {
img.src = ''; // Cancel loading
resolve({ status: 'error', statusCode: 'timeout', url });
}, 10000); // 10 second timeout (longer for slow networks)
img.onload = () => {
clearTimeout(timeout);
resolve({ status: 'loaded', statusCode: 200, url });
};
img.onerror = () => {
clearTimeout(timeout);
// Assume failed (could be 404 or network error)
resolve({ status: 'failed', statusCode: 404, url });
};
img.src = url;
});
},
parseCSV(csvText) {
// Split by newline but handle quoted fields with embedded newlines
const lines = [];
let currentLine = '';
let inQuotes = false;
for (let i = 0; i < csvText.length; i++) {
const char = csvText[i];
if (char === '"') {
inQuotes = !inQuotes;
currentLine += char;
} else if (char === '\n' && !inQuotes) {
if (currentLine.trim()) {
lines.push(currentLine);
}
currentLine = '';
} else {
currentLine += char;
}
}
// Add last line if exists
if (currentLine.trim()) {
lines.push(currentLine);
}
if (lines.length === 0) return [];
// Get headers from first line - parse CSV properly
const headers = this.parseCSVLine(lines[0]);
const data = [];
// Parse each line
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values = this.parseCSVLine(line);
const row = {};
headers.forEach((header, index) => {
if (values[index] !== undefined) {
row[header] = values[index];
}
});
data.push(row);
}
return data;
},
parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
// Add last field
result.push(current.trim());
return result;
},
generateImageUrl(peopleID3) {
if (!peopleID3 || peopleID3.trim() === '') {
return null;
}
// Primary URL format (works better): https://media.joshuaproject.net/public/assets/media/profiles/photos/p19007.jpg
// Fallback URL format: https://joshuaproject.net/assets/media/profiles/photos/p18194.jpg
return `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${peopleID3}.jpg`;
},
setDataSource(sourceId) {
if (this.dataSource !== sourceId) {
this.dataSource = sourceId;
// Save preference to localStorage
localStorage.setItem(LS('dataSource'), sourceId);
// Refetch data from new source
this.fetchPeopleGroups();
}
// No background photo test. It loaded EVERY photo, three at a time, to learn which ones
// fail: 932 photos, 132 MB by their Content-Length (2026-09-29; the 77 IMB photos alone are
// 115 MB), for as long as the page stayed open, whether or not anyone zoomed in.
// All 939 photo URLs answered 200. The canvas already loads only the photos in view and
// draws the grey box for any that fail, so the test bought nothing it did not already have.
},
addPrayer(groupId) {
@ -585,7 +169,6 @@ export const usePeopleGroupsStore = (pinia) => {
try {
const savedPrayers = localStorage.getItem(LS('prayers'));
const savedComments = localStorage.getItem(LS('comments'));
const savedSource = localStorage.getItem(LS('dataSource'));
if (savedPrayers) {
this.prayers = JSON.parse(savedPrayers);
@ -594,10 +177,6 @@ export const usePeopleGroupsStore = (pinia) => {
if (savedComments) {
this.comments = JSON.parse(savedComments);
}
if (savedSource && ['PRAY_API'].includes(savedSource)) {
this.dataSource = savedSource;
}
} catch (error) {
console.error('Error loading from localStorage:', error);
}

View file

@ -634,109 +634,6 @@ body {
height: 8px;
}
/* Source Selector Component */
.source-selector {
background: white;
border-bottom: 1px solid var(--border-color);
padding: 15px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.source-header {
display: flex;
align-items: center;
gap: 15px;
}
.source-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.current-source-label {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 4px 12px;
border-radius: 20px;
font-weight: 500;
}
.source-buttons {
display: flex;
gap: 10px;
}
.source-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #f9f9f9;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.source-btn:hover {
background: #f0f0f0;
border-color: var(--instagram-blue);
transform: translateY(-1px);
}
.source-btn.active {
background: #e7f3ff;
border-color: var(--instagram-blue);
border-width: 2px;
box-shadow: 0 0 0 2px rgba(0, 149, 246, 0.1);
}
.source-icon {
font-size: 18px;
}
.source-name {
color: var(--text-color);
}
.active-indicator {
font-size: 16px;
color: var(--instagram-blue);
font-weight: bold;
}
.source-info {
display: none; /* Hide detailed info in compact mode */
}
/* Responsive adjustments for source selector */
@media (max-width: 768px) {
.source-selector {
flex-direction: column;
align-items: flex-start;
}
.source-buttons {
width: 100%;
flex-direction: column;
}
.source-btn {
width: 100%;
}
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
@ -750,28 +647,6 @@ body {
background: #999;
}
/* People Group Name Link Styles */
.group-name-link {
color: #0095f6; /* Instagram blue */
text-decoration: underline;
cursor: pointer;
transition: color 0.2s;
}
.group-name-link:hover {
color: #0081d6; /* Darker blue on hover */
text-decoration: underline;
}
.group-name-link:visited {
color: #0095f6; /* Keep same color when visited */
}
.group-name-no-link {
color: #262626; /* Dark gray for non-linked names */
cursor: default;
}
/* SVG Link Styles for D3 Wall */
svg a {
cursor: pointer;

View file

@ -19,12 +19,11 @@
import PeopleGroupsWall from './src/components/PeopleGroupsWall.vue'
import CommentsDialog from './src/components/CommentsDialog.vue'
import PrayerDialog from './src/components/PrayerDialog.vue'
import SourceSelector from './src/components/SourceSelector.vue'
import { ensureD3 } from './src/wallD3.js'
export default {
name: 'WallOfPeopleGroups',
components: { PeopleGroupsWall, CommentsDialog, PrayerDialog, SourceSelector },
components: { PeopleGroupsWall, CommentsDialog, PrayerDialog },
data() {
return { ready: false, error: null, commentsDialogVisible: false, prayerDialogVisible: false, selectedGroup: null }
},
@ -49,7 +48,6 @@ export default {
<template>
<div class="app-container">
<source-selector />
<people-groups-wall v-if="ready" @open-comments="openCommentsDialog" @open-prayer="openPrayerDialog" />
<div v-else-if="error" class="wall-error">{{ $t('wall.drawLibError') }} {{ error }}</div>
<comments-dialog :visible="commentsDialogVisible" :group="selectedGroup" @close="closeCommentsDialog" />