Wall of People Groups: every Doxa people group on one zoomable prayer wall joins Experimental
The Instagram-style prayer wall (2,107 DX people groups as photo cards on a d3 canvas, with a pray dialog and comments) existed only as a no-build SPA whose components were template strings compiled in the browser. It is now a bundle: the components are SFCs in src/components, and the root is the app's own container without the router. The API developer panel does not ship. Pinia comes from the package. The canvas is found by ref, not document id, which cannot see into a shadow root. Font Awesome is registered on the document too, because @font-face does not load from a shadow root. Data is the live pray.doxa.life list. The uupg.doxa.life endpoint no longer answers, so that source and every photo URL built on it are gone. The card and both dialogs show the API's image_url instead; before this, every dialog read "No image". The 2.6 MB CSV fallback duplicated the live API and does not ship. Missing photos draw inline, not from via.placeholder.com. Prayers and comments stay in localStorage under a wall-pg: prefix, and the "Print (Coming Soon)" button, which only alerted, is gone. Measured on a served built tree: the leaf page, a script embed on another origin, the Research Gallery tab and a 390 px phone each load all 2,107 groups with photos and Font Awesome, 0 page errors, 0 failed requests. Zooming in and tapping a card's heart opens the prayer dialog with the group, its status and its photo.
This commit is contained in:
parent
63083372c8
commit
7fec74260f
16 changed files with 3365 additions and 7 deletions
14
plugin-designs/wall-of-people-groups/index.html
Normal file
14
plugin-designs/wall-of-people-groups/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>wall-of-people-groups (dev)</title>
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;background:#f3f3f1}wall-of-people-groups{position:fixed;inset:0;display:block}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<wall-of-people-groups profile-config='{"profile":"wall-of-people-groups"}'></wall-of-people-groups>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
plugin-designs/wall-of-people-groups/index.js
Normal file
28
plugin-designs/wall-of-people-groups/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* wall-of-people-groups / index.js — bundle entry (emits wall-of-people-groups.js), defines
|
||||||
|
* <wall-of-people-groups>. The standard ProfileLoader shape: the glob captures this folder's one
|
||||||
|
* profile; its components live in src/. The wall's components read the store as this.$store (the
|
||||||
|
* original app's global property), so it is created here, per element, on the element's Pinia.
|
||||||
|
*/
|
||||||
|
import { defineCustomElement } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ProfileLoader from '@map/ProfileLoader.vue'
|
||||||
|
import { createAppI18n } from '@map/i18n/index.js'
|
||||||
|
import { usePeopleGroupsStore } from './src/stores/peopleGroupsStore.js'
|
||||||
|
|
||||||
|
const profileModules = import.meta.glob('./*.vue')
|
||||||
|
|
||||||
|
const El = defineCustomElement(ProfileLoader, {
|
||||||
|
configureApp(app) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
|
const i18n = createAppI18n()
|
||||||
|
app.use(i18n)
|
||||||
|
app.provide('appI18n', i18n)
|
||||||
|
app.provide('profileModules', profileModules)
|
||||||
|
const store = usePeopleGroupsStore(pinia)
|
||||||
|
store.loadFromLocalStorage()
|
||||||
|
app.config.globalProperties.$store = store
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (typeof customElements !== 'undefined' && !customElements.get('wall-of-people-groups')) customElements.define('wall-of-people-groups', El)
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay" @click="closeDialog">
|
||||||
|
<div class="dialog-container" @click.stop>
|
||||||
|
<!-- Dialog Header -->
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h2>People Group Chats</h2>
|
||||||
|
<button class="close-btn" @click="closeDialog">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- People Group Image (Instagram style) -->
|
||||||
|
<div class="group-image-container">
|
||||||
|
<img
|
||||||
|
:src="getImageUrl()"
|
||||||
|
:alt="group?.name || 'People Group'"
|
||||||
|
class="group-image"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-name-dialog">{{ group?.name || 'Unknown Group' }}</div>
|
||||||
|
|
||||||
|
<!-- People Group Info -->
|
||||||
|
<div class="dialog-content">
|
||||||
|
<div class="group-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<strong>Status:</strong> {{ group?.status || 'Unknown' }}
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<strong>ID:</strong> {{ group?.ID || 'N/A' }}
|
||||||
|
</div>
|
||||||
|
<div class="info-row" v-if="group?.lat && group?.lng">
|
||||||
|
<strong>Location:</strong> {{ group.lat }}, {{ group.lng }}
|
||||||
|
</div>
|
||||||
|
<div class="info-row prayer-stat">
|
||||||
|
<span class="heart-icon">❤️</span>
|
||||||
|
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person has' : 'people have' }} prayed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prayer Button -->
|
||||||
|
<div class="prayer-action">
|
||||||
|
<button
|
||||||
|
class="prayer-btn"
|
||||||
|
@click="addPrayer"
|
||||||
|
:class="{ active: justPrayed }"
|
||||||
|
>
|
||||||
|
{{ justPrayed ? '✓ You prayed!' : '🙏 Pray for this group' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3>Prayers & Comments</h3>
|
||||||
|
|
||||||
|
<!-- Comment List -->
|
||||||
|
<div class="comments-list">
|
||||||
|
<div
|
||||||
|
v-if="comments.length === 0"
|
||||||
|
class="no-comments"
|
||||||
|
>
|
||||||
|
No prayers or comments yet. Be the first!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="comment in comments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="comment-item"
|
||||||
|
>
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-icon">💭</span>
|
||||||
|
<span class="comment-date">{{ formatDate(comment.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-text">{{ comment.text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Comment Form -->
|
||||||
|
<div class="add-comment">
|
||||||
|
<textarea
|
||||||
|
v-model="newComment"
|
||||||
|
placeholder="Share your prayer or thoughts..."
|
||||||
|
rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
></textarea>
|
||||||
|
<div class="comment-actions">
|
||||||
|
<span class="char-count">{{ newComment.length }}/500</span>
|
||||||
|
<button
|
||||||
|
@click="submitComment"
|
||||||
|
:disabled="!newComment.trim()"
|
||||||
|
class="submit-btn"
|
||||||
|
>
|
||||||
|
Post Comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
// Comments and Prayers Dialog Component
|
||||||
|
export default {
|
||||||
|
name: 'CommentsDialog',
|
||||||
|
props: {
|
||||||
|
group: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
newComment: '',
|
||||||
|
justPrayed: false,
|
||||||
|
imageError: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
prayerCount() {
|
||||||
|
if (!this.group || !this.$store) return 0;
|
||||||
|
// Access the prayers state directly
|
||||||
|
return this.$store.prayers?.[this.group.ID] || 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
comments() {
|
||||||
|
if (!this.group || !this.$store) return [];
|
||||||
|
// Access the comments state directly
|
||||||
|
return this.$store.comments?.[this.group.ID] || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeDialog() {
|
||||||
|
this.$emit('close');
|
||||||
|
this.newComment = '';
|
||||||
|
this.justPrayed = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
addPrayer() {
|
||||||
|
if (this.group && this.$store) {
|
||||||
|
this.$store.addPrayer(this.group.ID);
|
||||||
|
this.justPrayed = true;
|
||||||
|
|
||||||
|
// Reset after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.justPrayed = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
submitComment() {
|
||||||
|
if (this.newComment.trim() && this.group && this.$store) {
|
||||||
|
this.$store.addComment(
|
||||||
|
this.group.ID,
|
||||||
|
this.newComment.trim()
|
||||||
|
);
|
||||||
|
this.newComment = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
},
|
||||||
|
|
||||||
|
getImageUrl() {
|
||||||
|
if (this.imageError) {
|
||||||
|
return NO_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card's own photo (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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visible(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
// Reset state when dialog opens
|
||||||
|
this.justPrayed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
<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 || 'Unknown Group' }}
|
||||||
|
</a>
|
||||||
|
<span v-else class="group-name-no-link" title="This people group doesn't have a PeopleID3">
|
||||||
|
{{ group.name || group.post_title || 'Unknown Group' }}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<span class="group-status">{{ group.status || 'Status Unknown' }}</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>
|
||||||
|
|
@ -0,0 +1,509 @@
|
||||||
|
<template>
|
||||||
|
<div class="wall-container">
|
||||||
|
<div class="wall-header">
|
||||||
|
<h1>Wall of People Groups</h1>
|
||||||
|
<div class="wall-controls">
|
||||||
|
<button @click="toggleOrientation" class="control-btn orientation-btn">
|
||||||
|
<span class="btn-label">{{ isLandscape ? '📱 Switch to Portrait' : '🖼️ Switch to Landscape' }}</span>
|
||||||
|
<span class="current-mode">Current: {{ isLandscape ? 'Landscape (4ft×3ft)' : 'Portrait (3ft×4ft)' }}</span>
|
||||||
|
</button>
|
||||||
|
<button @click="resetZoom" class="control-btn">Reset Zoom</button>
|
||||||
|
<span class="group-count">{{ totalGroups }} Groups Loaded</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="canvas" class="wall-canvas"></div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading people groups...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Wall of People Groups - D3 Zoomable Whiteboard
|
||||||
|
export default {
|
||||||
|
name: 'PeopleGroupsWall',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
svg: null,
|
||||||
|
zoom: null,
|
||||||
|
cardWidth: 50,
|
||||||
|
cardHeight: 70,
|
||||||
|
cardSpacing: 8, // Increased horizontal spacing (was 2)
|
||||||
|
cardVerticalSpacing: 3, // Separate vertical spacing
|
||||||
|
canvasWidth: 0,
|
||||||
|
canvasHeight: 0,
|
||||||
|
isLandscape: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
loading() {
|
||||||
|
return this.$store?.loading || false;
|
||||||
|
},
|
||||||
|
|
||||||
|
totalGroups() {
|
||||||
|
return this.$store?.totalGroups || 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
peopleGroups() {
|
||||||
|
return this.$store?.peopleGroups || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initializeCanvas();
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchData() {
|
||||||
|
console.log('📊 Wall component fetching data...');
|
||||||
|
if (this.$store) {
|
||||||
|
await this.$store.fetchPeopleGroups();
|
||||||
|
console.log('🎨 Data fetched, rendering cards...');
|
||||||
|
this.renderCards();
|
||||||
|
} else {
|
||||||
|
console.error('❌ Store not available!');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initializeCanvas() {
|
||||||
|
// Set dimensions based on orientation
|
||||||
|
if (this.isLandscape) {
|
||||||
|
// 4ft x 3ft aspect ratio = 4:3 ratio (width:height) - LANDSCAPE
|
||||||
|
this.canvasWidth = 4000; // 4ft wide
|
||||||
|
this.canvasHeight = 3000; // 3ft tall
|
||||||
|
} else {
|
||||||
|
// 3ft x 4ft aspect ratio = 3:4 ratio (width:height) - PORTRAIT
|
||||||
|
this.canvasWidth = 3000; // 3ft wide
|
||||||
|
this.canvasHeight = 4000; // 4ft tall
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = d3.select(this.$refs.canvas);
|
||||||
|
|
||||||
|
// Create SVG
|
||||||
|
this.svg = container
|
||||||
|
.append('svg')
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', '100%')
|
||||||
|
.attr('viewBox', `0 0 ${this.canvasWidth} ${this.canvasHeight}`)
|
||||||
|
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||||||
|
|
||||||
|
// Create zoom behavior - start zoomed out to see everything
|
||||||
|
this.zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.1, 20])
|
||||||
|
.filter(function(event) {
|
||||||
|
// Allow zoom/pan except when trying to select text
|
||||||
|
// Prevent zoom/pan when clicking on text elements with user-select enabled
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
// Allow text selection on text elements
|
||||||
|
if (target.tagName === 'text' || target.tagName === 'tspan') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow normal zoom/pan for everything else
|
||||||
|
return !event.ctrlKey && !event.button;
|
||||||
|
})
|
||||||
|
.on('zoom', (event) => {
|
||||||
|
this.svg.select('.cards-container')
|
||||||
|
.attr('transform', event.transform);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.svg.call(this.zoom);
|
||||||
|
|
||||||
|
// Add background
|
||||||
|
this.svg.append('rect')
|
||||||
|
.attr('width', this.canvasWidth)
|
||||||
|
.attr('height', this.canvasHeight)
|
||||||
|
.attr('fill', '#fafafa');
|
||||||
|
|
||||||
|
// Create container for cards
|
||||||
|
this.svg.append('g')
|
||||||
|
.attr('class', 'cards-container');
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCards() {
|
||||||
|
console.log('🎨 Rendering cards...');
|
||||||
|
console.log('People groups available:', this.peopleGroups?.length || 0);
|
||||||
|
|
||||||
|
if (!this.peopleGroups || this.peopleGroups.length === 0) {
|
||||||
|
console.warn('⚠️ No people groups to render');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardsContainer = this.svg.select('.cards-container');
|
||||||
|
const totalGroups = this.peopleGroups.length;
|
||||||
|
|
||||||
|
// Calculate optimal grid layout based on 4:3 aspect ratio (LANDSCAPE)
|
||||||
|
// Canvas is 4000x3000 (4:3 ratio - 4ft wide × 3ft tall)
|
||||||
|
// We need to fit totalGroups cards
|
||||||
|
|
||||||
|
// Card aspect ratio (roughly portrait, like Instagram)
|
||||||
|
const cardAspectRatio = this.cardWidth / this.cardHeight; // ~0.71
|
||||||
|
|
||||||
|
// Canvas aspect ratio
|
||||||
|
const canvasAspectRatio = this.canvasWidth / this.canvasHeight; // 1.33 (4:3)
|
||||||
|
|
||||||
|
// Calculate columns and rows to maintain aspect ratio and fit all cards
|
||||||
|
// cols / rows should approximate canvasAspectRatio
|
||||||
|
// cols * rows should equal totalGroups
|
||||||
|
|
||||||
|
const cols = Math.ceil(Math.sqrt(totalGroups * canvasAspectRatio));
|
||||||
|
const rows = Math.ceil(totalGroups / cols);
|
||||||
|
|
||||||
|
console.log(`📐 Grid Layout: ${cols} columns × ${rows} rows = ${cols * rows} slots for ${totalGroups} groups`);
|
||||||
|
console.log(`📏 Canvas: ${this.canvasWidth}×${this.canvasHeight} (aspect: ${canvasAspectRatio.toFixed(2)})`);
|
||||||
|
|
||||||
|
// Calculate card size to fit the grid
|
||||||
|
const availableWidth = this.canvasWidth - (this.cardSpacing * (cols + 1));
|
||||||
|
const availableHeight = this.canvasHeight - (this.cardSpacing * (rows + 1));
|
||||||
|
|
||||||
|
const calculatedCardWidth = availableWidth / cols;
|
||||||
|
const calculatedCardHeight = availableHeight / rows;
|
||||||
|
|
||||||
|
// Use the smaller of the two to ensure cards fit
|
||||||
|
this.cardWidth = Math.min(calculatedCardWidth, calculatedCardHeight * 0.7);
|
||||||
|
this.cardHeight = this.cardWidth / 0.7; // Maintain card aspect ratio
|
||||||
|
|
||||||
|
console.log(`🎴 Card size: ${this.cardWidth.toFixed(1)}×${this.cardHeight.toFixed(1)}`);
|
||||||
|
console.log('📦 Sample group data:', this.peopleGroups[0]);
|
||||||
|
|
||||||
|
const totalWidth = this.cardWidth + this.cardSpacing;
|
||||||
|
const totalHeight = this.cardHeight + this.cardVerticalSpacing;
|
||||||
|
|
||||||
|
// Create card groups with better spacing
|
||||||
|
const cardGroups = cardsContainer
|
||||||
|
.selectAll('.card-group')
|
||||||
|
.data(this.peopleGroups)
|
||||||
|
.enter()
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'card-group')
|
||||||
|
.attr('transform', (d, i) => {
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
const x = col * totalWidth + this.cardSpacing;
|
||||||
|
const y = row * totalHeight + this.cardVerticalSpacing;
|
||||||
|
return `translate(${x}, ${y})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add card background
|
||||||
|
cardGroups
|
||||||
|
.append('rect')
|
||||||
|
.attr('width', this.cardWidth)
|
||||||
|
.attr('height', this.cardHeight)
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.attr('stroke', '#dbdbdb')
|
||||||
|
.attr('stroke-width', 0.5)
|
||||||
|
.attr('rx', 0)
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.style('pointer-events', 'none'); // Don't block text selection
|
||||||
|
|
||||||
|
// Calculate proportional font sizes - smaller for names
|
||||||
|
const nameFontSize = Math.max(3.5, this.cardWidth * 0.07);
|
||||||
|
const statusFontSize = Math.max(3, this.cardWidth * 0.055);
|
||||||
|
const iconSize = Math.max(8, this.cardWidth * 0.12);
|
||||||
|
const prayerFontSize = Math.max(3.5, this.cardWidth * 0.065);
|
||||||
|
const cardWidth = this.cardWidth;
|
||||||
|
const cardHeight = this.cardHeight;
|
||||||
|
|
||||||
|
// Add people group name - NO TRUNCATION, smaller font, with clickable link
|
||||||
|
cardGroups.each(function(d) {
|
||||||
|
const group = d3.select(this);
|
||||||
|
const peopleID3 = d.peopleID3 || d.PeopleID3;
|
||||||
|
const name = d.name || d.post_title || 'Unknown Group';
|
||||||
|
|
||||||
|
let nameElement;
|
||||||
|
|
||||||
|
// If has peopleID3, wrap in a link
|
||||||
|
if (peopleID3 && peopleID3.trim() !== '') {
|
||||||
|
const link = group.append('a')
|
||||||
|
.attr('href', `https://joshuaproject.net/people_groups/${peopleID3}`)
|
||||||
|
.attr('target', '_blank')
|
||||||
|
.attr('rel', 'noopener noreferrer')
|
||||||
|
.style('cursor', 'pointer');
|
||||||
|
|
||||||
|
nameElement = link.append('text')
|
||||||
|
.attr('x', cardWidth / 2)
|
||||||
|
.attr('y', cardHeight * 0.06)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', nameFontSize)
|
||||||
|
.attr('font-weight', 'bold')
|
||||||
|
.attr('fill', '#0095f6') // Blue for links
|
||||||
|
.attr('text-decoration', 'underline')
|
||||||
|
.style('user-select', 'text')
|
||||||
|
.style('pointer-events', 'all');
|
||||||
|
} else {
|
||||||
|
// No link, just text
|
||||||
|
nameElement = group.append('text')
|
||||||
|
.attr('x', cardWidth / 2)
|
||||||
|
.attr('y', cardHeight * 0.06)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', nameFontSize)
|
||||||
|
.attr('font-weight', 'bold')
|
||||||
|
.attr('fill', '#262626') // Dark gray for non-links
|
||||||
|
.style('user-select', 'text')
|
||||||
|
.style('pointer-events', 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fit in one line first
|
||||||
|
nameElement.text(name);
|
||||||
|
|
||||||
|
// If too long, wrap to two lines
|
||||||
|
if (nameElement.node().getComputedTextLength() > cardWidth * 0.95) {
|
||||||
|
nameElement.text('');
|
||||||
|
const words = name.split(' ');
|
||||||
|
const mid = Math.ceil(words.length / 2);
|
||||||
|
const line1 = words.slice(0, mid).join(' ');
|
||||||
|
const line2 = words.slice(mid).join(' ');
|
||||||
|
|
||||||
|
nameElement.append('tspan')
|
||||||
|
.attr('x', cardWidth / 2)
|
||||||
|
.attr('dy', 0)
|
||||||
|
.text(line1);
|
||||||
|
|
||||||
|
nameElement.append('tspan')
|
||||||
|
.attr('x', cardWidth / 2)
|
||||||
|
.attr('dy', nameFontSize * 1.1)
|
||||||
|
.text(line2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add status (moved down to accommodate 2-line names)
|
||||||
|
cardGroups
|
||||||
|
.append('text')
|
||||||
|
.attr('x', cardWidth / 2)
|
||||||
|
.attr('y', this.cardHeight * 0.14)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', statusFontSize)
|
||||||
|
.attr('fill', '#8e8e8e')
|
||||||
|
.style('user-select', 'text')
|
||||||
|
.style('pointer-events', 'all')
|
||||||
|
.text(d => d.status || '');
|
||||||
|
|
||||||
|
// Add image or placeholder - FULL WIDTH like Instagram
|
||||||
|
const imageY = this.cardHeight * 0.18;
|
||||||
|
const imageHeight = this.cardHeight * 0.50; // Larger image
|
||||||
|
const imageWidth = this.cardWidth; // FULL WIDTH
|
||||||
|
|
||||||
|
// Background rectangle for image area
|
||||||
|
cardGroups
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', imageY)
|
||||||
|
.attr('width', imageWidth)
|
||||||
|
.attr('height', imageHeight)
|
||||||
|
.attr('fill', '#fafafa')
|
||||||
|
.attr('stroke', '#efefef')
|
||||||
|
.attr('stroke-width', 0.5)
|
||||||
|
.attr('rx', 0); // NO rounded corners
|
||||||
|
|
||||||
|
// Add images or placeholders
|
||||||
|
cardGroups.each(function(d) {
|
||||||
|
const group = d3.select(this);
|
||||||
|
|
||||||
|
if (d.imageUrl) {
|
||||||
|
// Add SVG image element with proper aspect ratio
|
||||||
|
group
|
||||||
|
.append('image')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', imageY)
|
||||||
|
.attr('width', imageWidth)
|
||||||
|
.attr('height', imageHeight)
|
||||||
|
.attr('href', d.imageUrl)
|
||||||
|
.attr('preserveAspectRatio', 'xMidYMid slice')
|
||||||
|
.style('pointer-events', 'none')
|
||||||
|
.on('error', function() {
|
||||||
|
console.log('Image failed to load:', d.imageUrl);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add placeholder icon/text if no image URL
|
||||||
|
group
|
||||||
|
.append('text')
|
||||||
|
.attr('x', cardWidth / 2)
|
||||||
|
.attr('y', imageY + imageHeight / 2)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', iconSize * 1.5)
|
||||||
|
.attr('fill', '#c7c7c7')
|
||||||
|
.text('📷')
|
||||||
|
.style('pointer-events', 'none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add action buttons row with FontAwesome icons - Instagram style
|
||||||
|
const buttonY = this.cardHeight * 0.72;
|
||||||
|
const buttonSize = iconSize * 0.7; // Make buttons smaller (was iconSize)
|
||||||
|
const buttonSpacing = buttonSize * 1.6; // Tighter spacing
|
||||||
|
const totalButtonWidth = (buttonSize * 3) + (buttonSpacing * 2);
|
||||||
|
const startX = (cardWidth - totalButtonWidth) / 2; // Center the button group
|
||||||
|
|
||||||
|
// Heart button (using foreignObject for FontAwesome)
|
||||||
|
const heartButton = cardGroups
|
||||||
|
.append('foreignObject')
|
||||||
|
.attr('x', startX)
|
||||||
|
.attr('y', buttonY)
|
||||||
|
.attr('width', buttonSize)
|
||||||
|
.attr('height', buttonSize)
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.style('overflow', 'visible')
|
||||||
|
.on('click', (event, d) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.openPrayerDialog(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
heartButton
|
||||||
|
.append('xhtml:div')
|
||||||
|
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||||
|
.style('width', '100%')
|
||||||
|
.style('height', '100%')
|
||||||
|
.style('display', 'flex')
|
||||||
|
.style('align-items', 'center')
|
||||||
|
.style('justify-content', 'center')
|
||||||
|
.html(function(d) {
|
||||||
|
// Combine API prayer count with any local session prayers
|
||||||
|
const apiCount = Number(d.peoplePraying || 0);
|
||||||
|
const localCount = self.$store?.prayers?.[d.ID] || 0;
|
||||||
|
const prayerCount = apiCount + localCount;
|
||||||
|
const iconClass = prayerCount > 0 ? 'fas fa-heart' : 'far fa-heart';
|
||||||
|
const iconColor = prayerCount > 0 ? '#ed4956' : '#262626';
|
||||||
|
return '<i class="' + iconClass + '" style="font-size: ' + buttonSize + 'px; color: ' + iconColor + ';"></i>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Comment button
|
||||||
|
const commentButton = cardGroups
|
||||||
|
.append('foreignObject')
|
||||||
|
.attr('x', startX + buttonSpacing)
|
||||||
|
.attr('y', buttonY)
|
||||||
|
.attr('width', buttonSize)
|
||||||
|
.attr('height', buttonSize)
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.style('overflow', 'visible')
|
||||||
|
.on('click', (event, d) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.openComments(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
commentButton
|
||||||
|
.append('xhtml:div')
|
||||||
|
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||||
|
.style('width', '100%')
|
||||||
|
.style('height', '100%')
|
||||||
|
.style('display', 'flex')
|
||||||
|
.style('align-items', 'center')
|
||||||
|
.style('justify-content', 'center')
|
||||||
|
.html('<i class="far fa-comment" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||||
|
|
||||||
|
// Share button
|
||||||
|
const shareButton = cardGroups
|
||||||
|
.append('foreignObject')
|
||||||
|
.attr('x', startX + (buttonSpacing * 2))
|
||||||
|
.attr('y', buttonY)
|
||||||
|
.attr('width', buttonSize)
|
||||||
|
.attr('height', buttonSize)
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.style('overflow', 'visible')
|
||||||
|
.on('click', (event, d) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
alert('Sharing functionality coming later!');
|
||||||
|
});
|
||||||
|
|
||||||
|
shareButton
|
||||||
|
.append('xhtml:div')
|
||||||
|
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||||
|
.style('width', '100%')
|
||||||
|
.style('height', '100%')
|
||||||
|
.style('display', 'flex')
|
||||||
|
.style('align-items', 'center')
|
||||||
|
.style('justify-content', 'center')
|
||||||
|
.html('<i class="far fa-paper-plane" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||||
|
|
||||||
|
// Add prayer count text
|
||||||
|
cardGroups
|
||||||
|
.append('text')
|
||||||
|
.attr('x', startX)
|
||||||
|
.attr('y', buttonY + buttonSize + prayerFontSize + 2)
|
||||||
|
.attr('text-anchor', 'start')
|
||||||
|
.attr('font-size', prayerFontSize)
|
||||||
|
.attr('fill', '#262626')
|
||||||
|
.attr('font-weight', '600')
|
||||||
|
.attr('class', 'prayer-text')
|
||||||
|
.style('user-select', 'text')
|
||||||
|
.style('pointer-events', 'all')
|
||||||
|
.text(d => {
|
||||||
|
// Combine API prayer count with any local session prayers
|
||||||
|
const apiCount = Number(d.peoplePraying || 0);
|
||||||
|
const localCount = this.$store?.prayers?.[d.ID] || 0;
|
||||||
|
const count = apiCount + localCount;
|
||||||
|
if (count === 0) return '0 prayed';
|
||||||
|
return `${count} ${count === 1 ? 'person' : 'people'} prayed`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add "View prayers" button
|
||||||
|
cardGroups
|
||||||
|
.append('text')
|
||||||
|
.attr('x', startX)
|
||||||
|
.attr('y', buttonY + buttonSize + (prayerFontSize * 2) + 4)
|
||||||
|
.attr('text-anchor', 'start')
|
||||||
|
.attr('font-size', prayerFontSize)
|
||||||
|
.attr('fill', '#8e8e8e')
|
||||||
|
.attr('cursor', 'pointer')
|
||||||
|
.attr('class', 'view-comments-text')
|
||||||
|
.style('user-select', 'none')
|
||||||
|
.style('pointer-events', 'all')
|
||||||
|
.text('View prayers')
|
||||||
|
.on('click', (event, d) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.openComments(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Rendered ${totalGroups} cards in ${cols}×${rows} grid`);
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePrayer(group) {
|
||||||
|
// This method is deprecated - now using openPrayerDialog
|
||||||
|
this.openPrayerDialog(group);
|
||||||
|
},
|
||||||
|
|
||||||
|
openPrayerDialog(group) {
|
||||||
|
this.$emit('open-prayer', group);
|
||||||
|
},
|
||||||
|
|
||||||
|
openComments(group) {
|
||||||
|
this.$emit('open-comments', group);
|
||||||
|
},
|
||||||
|
|
||||||
|
truncateText(text, maxLength) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetZoom() {
|
||||||
|
this.svg.transition()
|
||||||
|
.duration(750)
|
||||||
|
.call(this.zoom.transform, d3.zoomIdentity);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleOrientation() {
|
||||||
|
this.isLandscape = !this.isLandscape;
|
||||||
|
|
||||||
|
// Clear existing canvas
|
||||||
|
d3.select(this.$refs.canvas).selectAll('*').remove();
|
||||||
|
|
||||||
|
// Reinitialize with new orientation
|
||||||
|
this.initializeCanvas();
|
||||||
|
|
||||||
|
// Re-render cards
|
||||||
|
if (this.peopleGroups && this.peopleGroups.length > 0) {
|
||||||
|
this.renderCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📐 Switched to ${this.isLandscape ? 'LANDSCAPE (4ft×3ft)' : 'PORTRAIT (3ft×4ft)'}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
printWall() {
|
||||||
|
alert('Print functionality coming later!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="dialog-overlay" @click.self="closeDialog">
|
||||||
|
<div class="dialog-container prayer-dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h2>Prayer Tools</h2>
|
||||||
|
<button @click="closeDialog" class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-image-container">
|
||||||
|
<img
|
||||||
|
:src="getImageUrl()"
|
||||||
|
:alt="group?.name || 'People Group'"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-name-dialog">
|
||||||
|
{{ group?.name || 'Unknown' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-content">
|
||||||
|
<div class="group-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<strong>Status:</strong> {{ group?.status || 'unengaged' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hasPrayed" class="prayer-prompt">
|
||||||
|
<h3>Are you praying right now for this people group?</h3>
|
||||||
|
<button @click="confirmPrayer" class="prayer-confirm-btn">
|
||||||
|
<i class="far fa-heart"></i>
|
||||||
|
Yes, I'm Praying
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="prayer-confirmed">
|
||||||
|
<div class="confirmed-message">
|
||||||
|
<i class="fas fa-heart" style="color: #ed4956;"></i>
|
||||||
|
<span>Thank you for praying!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prayer-input-section">
|
||||||
|
<h4>Share your prayer (optional)</h4>
|
||||||
|
<textarea
|
||||||
|
v-model="prayerText"
|
||||||
|
placeholder="Type your prayer here..."
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
@click="sendPrayer"
|
||||||
|
:disabled="!prayerText.trim()"
|
||||||
|
class="send-prayer-btn"
|
||||||
|
>
|
||||||
|
Send Prayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
export default {
|
||||||
|
name: 'PrayerDialog',
|
||||||
|
props: {
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasPrayed: false,
|
||||||
|
prayerText: '',
|
||||||
|
imageError: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isOpen(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
// Check if user already prayed for this group
|
||||||
|
// Access the prayers state directly
|
||||||
|
const prayerCount = this.$store?.prayers?.[this.group?.ID] || 0;
|
||||||
|
this.hasPrayed = prayerCount > 0;
|
||||||
|
this.prayerText = '';
|
||||||
|
this.imageError = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeDialog() {
|
||||||
|
this.$emit('close');
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmPrayer() {
|
||||||
|
if (this.group?.ID) {
|
||||||
|
this.$store.addPrayer(this.group.ID);
|
||||||
|
this.hasPrayed = true;
|
||||||
|
|
||||||
|
// Emit event to update the card's heart icon
|
||||||
|
this.$emit('prayer-confirmed', this.group.ID);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendPrayer() {
|
||||||
|
if (this.prayerText.trim() && this.group?.ID) {
|
||||||
|
// Add prayer to comments as a special type
|
||||||
|
const pseudonym = this.generatePseudonym();
|
||||||
|
const prayerComment = `🙏 ${pseudonym} prayed: ${this.prayerText.trim()}`;
|
||||||
|
|
||||||
|
this.$store.addComment(this.group.ID, prayerComment);
|
||||||
|
|
||||||
|
this.prayerText = '';
|
||||||
|
|
||||||
|
// Close dialog after sending
|
||||||
|
setTimeout(() => {
|
||||||
|
this.closeDialog();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generatePseudonym() {
|
||||||
|
const adjectives = ['Faithful', 'Devoted', 'Humble', 'Earnest', 'Hopeful', 'Grateful', 'Compassionate'];
|
||||||
|
const nouns = ['Servant', 'Believer', 'Intercessor', 'Warrior', 'Witness', 'Disciple', 'Friend'];
|
||||||
|
|
||||||
|
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||||
|
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
const num = Math.floor(Math.random() * 999) + 1;
|
||||||
|
|
||||||
|
return `${adj} ${noun} ${num}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getImageUrl() {
|
||||||
|
if (this.imageError) {
|
||||||
|
return NO_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same photo the card shows (the live API's image_url). This used to build a URL on
|
||||||
|
// uupg.doxa.life, which no longer answers, so every dialog fell back to "No image".
|
||||||
|
return this.group?.imageUrl || NO_IMAGE;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleImageError(event) {
|
||||||
|
this.imageError = true;
|
||||||
|
event.target.src = NO_IMAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="sources.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">
|
||||||
|
<i class="fas fa-info-circle"></i> CSV includes ~1,439 groups with images from Joshua Project
|
||||||
|
</p>
|
||||||
|
<p v-if="dataSource === 'DOXA_API'" class="api-info">
|
||||||
|
<i class="fas fa-info-circle"></i> WordPress API — descriptions & photos, no prayer data
|
||||||
|
</p>
|
||||||
|
<p v-if="dataSource === 'PRAY_API'" class="api-info">
|
||||||
|
<i class="fas fa-info-circle"></i> 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) {
|
||||||
|
console.log('🔄 Switching to source:', 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>
|
||||||
|
|
@ -0,0 +1,635 @@
|
||||||
|
// People Groups Store - Manages data from the Doxa prayer API
|
||||||
|
// Moved 2026-09-25 from the wall-of-people-groups no-build SPA: Pinia now comes from the package
|
||||||
|
// (it was the Pinia CDN global), localStorage keys carry a 'wall-pg:' prefix so an embedded wall
|
||||||
|
// cannot collide with the host page's own keys, and the source list is the one live API.
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
const LS = (k) => 'wall-pg:' + k
|
||||||
|
|
||||||
|
export const usePeopleGroupsStore = (pinia) => {
|
||||||
|
|
||||||
|
return defineStore('peopleGroups', {
|
||||||
|
state: () => ({
|
||||||
|
peopleGroups: [],
|
||||||
|
loading: false,
|
||||||
|
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.
|
||||||
|
availableSources: [
|
||||||
|
{ id: 'PRAY_API', name: 'Prayer.Tools API (Live)', url: 'https://pray.doxa.life/api/people-groups/list', description: 'Live prayer data with images' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
totalGroups: (state) => state.peopleGroups.length,
|
||||||
|
|
||||||
|
getPrayerCount: (state) => (groupId) => {
|
||||||
|
return state.prayers[groupId] || 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
getComments: (state) => (groupId) => {
|
||||||
|
return state.comments[groupId] || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
currentSource: (state) => {
|
||||||
|
return state.availableSources.find(s => s.id === state.dataSource);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchPeopleGroups() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
console.log(`🔄 Fetching people groups from ${this.dataSource}...`);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Successfully loaded ${this.peopleGroups.length} people groups from ${this.dataSource}!`);
|
||||||
|
console.log('First 3 groups:', this.peopleGroups.slice(0, 3));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error fetching people groups from ${this.dataSource}:`, error);
|
||||||
|
this.error = error.message;
|
||||||
|
this.peopleGroups = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchFromPrayAPI() {
|
||||||
|
const source = this.availableSources.find(s => s.id === 'PRAY_API');
|
||||||
|
const apiUrl = source.url;
|
||||||
|
console.log('🙏 Prayer.Tools API URL:', apiUrl);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against HTML responses (bot protection, Nuxt SPA shell)
|
||||||
|
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();
|
||||||
|
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
console.error('Unexpected API response format:', data);
|
||||||
|
this.peopleGroups = [];
|
||||||
|
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 || '';
|
||||||
|
const blockLabel = group.wagf_block?.label || group.wagf_block || '';
|
||||||
|
const countryLabel = group.country_code?.label || group.country_code || '';
|
||||||
|
const countryCode = group.country_code?.value || group.country_code || '';
|
||||||
|
const religionLabel = group.religion?.label || group.religion || '';
|
||||||
|
const religionCode = group.religion?.value || '';
|
||||||
|
const affinityLabel = group.rop1?.label || '';
|
||||||
|
|
||||||
|
// Image URL — API provides direct URLs, with regional fallbacks for no-photo groups
|
||||||
|
const hasPhoto = group.has_photo === true || group.has_photo === 'true' || group.has_photo === 1;
|
||||||
|
const imageUrl = group.image_url || noPhotoPlaceholder;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Standard fields
|
||||||
|
ID: group.slug || `pray-${Math.random().toString(36).slice(2)}`,
|
||||||
|
post_title: group.name || 'Unknown',
|
||||||
|
name: group.name || 'Unknown',
|
||||||
|
slug: group.slug || '',
|
||||||
|
status: '',
|
||||||
|
country: countryLabel,
|
||||||
|
countryCode: countryCode,
|
||||||
|
region: regionLabel,
|
||||||
|
wagfBlock: blockLabel,
|
||||||
|
population: group.population || '',
|
||||||
|
religion: religionLabel,
|
||||||
|
religionCode: religionCode,
|
||||||
|
affinityBlock: affinityLabel,
|
||||||
|
latitude: group.latitude || null,
|
||||||
|
longitude: group.longitude || null,
|
||||||
|
description: group.location_description || '',
|
||||||
|
|
||||||
|
// Prayer & engagement data (THE KEY FIELDS!)
|
||||||
|
peoplePraying: Number(group.people_praying || 0),
|
||||||
|
peopleCommitted: Number(group.people_committed || 0),
|
||||||
|
adoptedByChurches: Number(group.adopted_by_churches || 0),
|
||||||
|
|
||||||
|
// Image fields
|
||||||
|
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;
|
||||||
|
|
||||||
|
console.log(`📊 Prayer.Tools API stats:`);
|
||||||
|
console.log(` Total groups: ${this.peopleGroups.length}`);
|
||||||
|
console.log(` 🙏 With prayer: ${withPrayer}`);
|
||||||
|
console.log(` 👥 With committed workers: ${withCommitted}`);
|
||||||
|
console.log(` ⛪ Adopted by churches: ${withAdoption}`);
|
||||||
|
console.log(` 📸 With photos: ${withImages}`);
|
||||||
|
|
||||||
|
// Start background image testing
|
||||||
|
this.testImagesInBackground();
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchFromAPI() {
|
||||||
|
const source = this.availableSources.find(s => s.id === 'DOXA_API');
|
||||||
|
const apiUrl = source.url;
|
||||||
|
console.log('API URL:', apiUrl);
|
||||||
|
|
||||||
|
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": [...] }
|
||||||
|
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
console.log(`📸 Image stats: ${withImages} with photos, ${withPlaceholder} using placeholder`);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
console.log('CSV URL:', csvUrl);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
console.log(`✅ Parsed ${parsedData.length} total rows`);
|
||||||
|
console.log(` ${withPeopleID3} have PeopleID3, ${withoutPeopleID3} without`);
|
||||||
|
console.log(` ${uniqueClusters} unique PeopleID3 clusters`);
|
||||||
|
console.log('Top 5 largest clusters:',
|
||||||
|
Object.entries(clusterStats)
|
||||||
|
.sort((a, b) => b[1].total - a[1].total)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([id, stats]) => `PeopleID3 ${id}: ${stats.total} groups`));
|
||||||
|
|
||||||
|
// Start background image testing (non-blocking)
|
||||||
|
this.testImagesInBackground();
|
||||||
|
},
|
||||||
|
|
||||||
|
async testImagesInBackground() {
|
||||||
|
console.log('🔍 Starting background image verification...');
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
console.log(` Testing ${totalToTest} unique images...`);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
console.log(` Progress: ${tested}/${totalToTest} tested (${loaded} ✅, ${failed} ❌, ${errors} ⚠️)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay between batches to avoid rate limiting
|
||||||
|
if (i + batchSize < entries.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🎉 Image testing complete!`);
|
||||||
|
console.log(` ✅ ${loaded} images loaded successfully`);
|
||||||
|
console.log(` ❌ ${failed} images not found (404) after retries`);
|
||||||
|
console.log(` ⚠️ ${errors} network/timeout errors after retries`);
|
||||||
|
console.log(` Note: Errors may be due to rate limiting or slow network`);
|
||||||
|
},
|
||||||
|
|
||||||
|
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`;
|
||||||
|
console.log(` 🔄 Trying fallback URL for PeopleID3 ${peopleID3}...`);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
console.log('CSV Headers found:', headers.length, 'columns');
|
||||||
|
console.log('Key columns - PEID:', headers.indexOf('PEID'), 'Name:', headers.indexOf('Name'), 'PeopleID3:', headers.indexOf('PeopleID3'));
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addPrayer(groupId) {
|
||||||
|
if (!this.prayers[groupId]) {
|
||||||
|
this.prayers[groupId] = 0;
|
||||||
|
}
|
||||||
|
this.prayers[groupId]++;
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem(LS('prayers'), JSON.stringify(this.prayers));
|
||||||
|
},
|
||||||
|
|
||||||
|
addComment(groupId, comment) {
|
||||||
|
if (!this.comments[groupId]) {
|
||||||
|
this.comments[groupId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.comments[groupId].push({
|
||||||
|
id: Date.now(),
|
||||||
|
text: comment,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem(LS('comments'), JSON.stringify(this.comments));
|
||||||
|
},
|
||||||
|
|
||||||
|
loadFromLocalStorage() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(pinia);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1162
plugin-designs/wall-of-people-groups/src/wall.css
Normal file
1162
plugin-designs/wall-of-people-groups/src/wall.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"type": "monolithic",
|
||||||
|
"label": "Wall of People Groups",
|
||||||
|
"note": "Monolithic composite (capability 3): ONE root .vue nests the zoomable d3 wall, its cards, and the prayer and comments dialogs. index.js registers exactly ONE tag (wall-of-people-groups); none of the inner components is independently embeddable."
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* wall-of-people-groups.vue — Wall of People Groups: every Doxa people group on one zoomable wall.
|
||||||
|
*
|
||||||
|
* @description Every Doxa people group as a card on one zoomable prayer wall
|
||||||
|
* @profile wall-of-people-groups
|
||||||
|
* @bundle wall-of-people-groups
|
||||||
|
* @element wall-of-people-groups
|
||||||
|
*
|
||||||
|
* MOVED 2026-09-25 from the wall-of-people-groups no-build SPA (far-fast-tools, read-only). Its
|
||||||
|
* components were plain objects with template strings compiled in the browser; they are now SFCs
|
||||||
|
* in src/components. This file is the app container from its main.js, without the router (the
|
||||||
|
* only other route was an API developer panel, which does not ship).
|
||||||
|
*
|
||||||
|
* Data: the live pray.doxa.life people-group list (2,107 groups, with photos and prayer counts).
|
||||||
|
* Prayers and comments stay in this browser's localStorage, under a 'wall-pg:' prefix.
|
||||||
|
* d3 and Font Awesome are CDN globals, loaded on first mount.
|
||||||
|
*/
|
||||||
|
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'
|
||||||
|
|
||||||
|
const D3_JS = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'
|
||||||
|
const FA_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'
|
||||||
|
let d3Promise = null
|
||||||
|
function ensureD3() {
|
||||||
|
if (typeof window === 'undefined' || window.d3) return Promise.resolve()
|
||||||
|
if (!d3Promise) d3Promise = new Promise((res, rej) => {
|
||||||
|
const s = document.createElement('script'); s.src = D3_JS
|
||||||
|
s.onload = () => res(); s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load')) }
|
||||||
|
document.head.appendChild(s)
|
||||||
|
})
|
||||||
|
return d3Promise
|
||||||
|
}
|
||||||
|
// Font Awesome's @font-face only registers from the DOCUMENT; its classes must also be inside the
|
||||||
|
// shadow root. So the stylesheet goes in both places.
|
||||||
|
function ensureFontAwesome(root) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (!document.head.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||||
|
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; document.head.appendChild(l)
|
||||||
|
}
|
||||||
|
if (root && root !== document && !root.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||||
|
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; root.prepend(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'WallOfPeopleGroups',
|
||||||
|
components: { PeopleGroupsWall, CommentsDialog, PrayerDialog, SourceSelector },
|
||||||
|
data() {
|
||||||
|
return { ready: false, error: null, commentsDialogVisible: false, prayerDialogVisible: false, selectedGroup: null }
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
ensureFontAwesome(this.$el?.getRootNode?.())
|
||||||
|
ensureD3().then(() => { this.ready = true }, (e) => { this.error = e.message })
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openCommentsDialog(group) { this.selectedGroup = group; this.commentsDialogVisible = true },
|
||||||
|
closeCommentsDialog() {
|
||||||
|
this.commentsDialogVisible = false
|
||||||
|
setTimeout(() => { this.selectedGroup = null }, 300)
|
||||||
|
},
|
||||||
|
openPrayerDialog(group) { this.selectedGroup = group; this.prayerDialogVisible = true },
|
||||||
|
closePrayerDialog() {
|
||||||
|
this.prayerDialogVisible = false
|
||||||
|
setTimeout(() => { if (!this.commentsDialogVisible) this.selectedGroup = null }, 300)
|
||||||
|
},
|
||||||
|
handlePrayerConfirmed() { /* PrayerDialog already updated the store */ },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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">The wall could not load its drawing library: {{ error }}</div>
|
||||||
|
<comments-dialog :visible="commentsDialogVisible" :group="selectedGroup" @close="closeCommentsDialog" />
|
||||||
|
<prayer-dialog :is-open="prayerDialogVisible" :group="selectedGroup" @close="closePrayerDialog" @prayer-confirmed="handlePrayerConfirmed" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style src="./src/wall.css"></style>
|
||||||
|
<style>
|
||||||
|
:host { display: block; position: relative; min-height: 600px; }
|
||||||
|
.app-container { position: relative; width: 100%; height: 100%; }
|
||||||
|
.wall-error { padding: 24px; color: #3b463d; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
34
public/doxa-maps/doxa-maps-spa/manifest.json
generated
34
public/doxa-maps/doxa-maps-spa/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
||||||
"schema": "doxa-maps-manifest@1",
|
"schema": "doxa-maps-manifest@1",
|
||||||
"generatedFrom": "plugin-designs/*/*.vue",
|
"generatedFrom": "plugin-designs/*/*.vue",
|
||||||
"embedBase": ".",
|
"embedBase": ".",
|
||||||
"bundleCount": 22,
|
"bundleCount": 23,
|
||||||
"profileCount": 34,
|
"profileCount": 35,
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"apiBaseUrl": "https://pray.doxa.life",
|
"apiBaseUrl": "https://pray.doxa.life",
|
||||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||||
|
|
@ -837,7 +837,7 @@
|
||||||
"bundle": "pplr-data-maps",
|
"bundle": "pplr-data-maps",
|
||||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||||
"output": "doxa-maps/pplr-data-maps/pplr-data-maps.js",
|
"output": "doxa-maps/pplr-data-maps/pplr-data-maps.js",
|
||||||
"integrity": "sha384-7Fp8ORq9SNmtZLwCG1euRM+dGy6sDgD/+Ts4s1B1WVk8XKuMJpYr3dCjoK80zvJp",
|
"integrity": "sha384-+EOcMT3P42D39HGIjZBbJ6waYI2yJDhNDwNf+8wFruaHqEhdYYa36M+iLIT5fRiH",
|
||||||
"elements": [
|
"elements": [
|
||||||
"pplr-data-maps"
|
"pplr-data-maps"
|
||||||
],
|
],
|
||||||
|
|
@ -853,7 +853,7 @@
|
||||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||||
"embed": {
|
"embed": {
|
||||||
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-7Fp8ORq9SNmtZLwCG1euRM+dGy6sDgD/+Ts4s1B1WVk8XKuMJpYr3dCjoK80zvJp\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-+EOcMT3P42D39HGIjZBbJ6waYI2yJDhNDwNf+8wFruaHqEhdYYa36M+iLIT5fRiH\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||||
},
|
},
|
||||||
"bundle": "pplr-data-maps"
|
"bundle": "pplr-data-maps"
|
||||||
}
|
}
|
||||||
|
|
@ -1152,6 +1152,32 @@
|
||||||
"bundle": "upg-neighbor"
|
"bundle": "upg-neighbor"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bundle": "wall-of-people-groups",
|
||||||
|
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||||
|
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||||
|
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
|
||||||
|
"elements": [
|
||||||
|
"wall-of-people-groups"
|
||||||
|
],
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile": "wall-of-people-groups",
|
||||||
|
"file": "wall-of-people-groups.vue",
|
||||||
|
"description": "Every Doxa people group as a card on one zoomable prayer wall",
|
||||||
|
"element": "wall-of-people-groups",
|
||||||
|
"type": "monolithic",
|
||||||
|
"isMap": false,
|
||||||
|
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
|
||||||
|
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||||
|
"embed": {
|
||||||
|
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||||
|
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||||
|
},
|
||||||
|
"bundle": "wall-of-people-groups"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
30
public/doxa-maps/manifest.json
generated
30
public/doxa-maps/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
||||||
"schema": "doxa-maps-manifest@1",
|
"schema": "doxa-maps-manifest@1",
|
||||||
"generatedFrom": "plugin-designs/*/*.vue",
|
"generatedFrom": "plugin-designs/*/*.vue",
|
||||||
"embedBase": ".",
|
"embedBase": ".",
|
||||||
"bundleCount": 22,
|
"bundleCount": 23,
|
||||||
"profileCount": 34,
|
"profileCount": 35,
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"apiBaseUrl": "https://pray.doxa.life",
|
"apiBaseUrl": "https://pray.doxa.life",
|
||||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||||
|
|
@ -1152,6 +1152,32 @@
|
||||||
"bundle": "upg-neighbor"
|
"bundle": "upg-neighbor"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bundle": "wall-of-people-groups",
|
||||||
|
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||||
|
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||||
|
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
|
||||||
|
"elements": [
|
||||||
|
"wall-of-people-groups"
|
||||||
|
],
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile": "wall-of-people-groups",
|
||||||
|
"file": "wall-of-people-groups.vue",
|
||||||
|
"description": "Every Doxa people group as a card on one zoomable prayer wall",
|
||||||
|
"element": "wall-of-people-groups",
|
||||||
|
"type": "monolithic",
|
||||||
|
"isMap": false,
|
||||||
|
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
|
||||||
|
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||||
|
"embed": {
|
||||||
|
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||||
|
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||||
|
},
|
||||||
|
"bundle": "wall-of-people-groups"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
public/doxa-maps/wall-of-people-groups/wall-of-people-groups.js
generated
Normal file
75
public/doxa-maps/wall-of-people-groups/wall-of-people-groups.js
generated
Normal file
File diff suppressed because one or more lines are too long
192
public/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html
generated
Normal file
192
public/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html
generated
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>wall-of-people-groups · wall-of-people-groups</title>
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||||
|
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||||
|
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||||
|
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||||
|
<style>
|
||||||
|
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||||
|
browser URL shares the map itself. */
|
||||||
|
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||||
|
#slot, wall-of-people-groups { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
|
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||||
|
padding: 24px; text-align: center; color: #9aa7b8;
|
||||||
|
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
#status.err { color: #f87171; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="slot"></div>
|
||||||
|
<div id="status">loading…</div>
|
||||||
|
|
||||||
|
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||||
|
<!-- Bundle IIFE — registers the <wall-of-people-groups> custom element. -->
|
||||||
|
<script src="../wall-of-people-groups.js" integrity="sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||||
|
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||||
|
// 2. ?tk= URL param — token carried in the shared link
|
||||||
|
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||||
|
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||||
|
// *endpoint* is resolved and fetched:
|
||||||
|
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||||
|
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||||
|
// means "read that global"; anything else is used as a URL — so both a global
|
||||||
|
// name and a plain URL are safe (no SyntaxError footgun).
|
||||||
|
(function () {
|
||||||
|
var slot = document.getElementById('slot');
|
||||||
|
var status = document.getElementById('status');
|
||||||
|
var BASE_CONFIG = {"profile":"wall-of-people-groups","dataSource":"pray-tools","instanceId":"wall-of-people-groups-wall-of-people-groups","assetsBase":"../../assets"};
|
||||||
|
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||||
|
// the ladder below is skipped entirely and the element mounts immediately.
|
||||||
|
var TOKEN_FREE = true;
|
||||||
|
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
|
||||||
|
// rather than this page's profile-config. With no baked token (the public copy), it must get
|
||||||
|
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
|
||||||
|
var NESTS_MAPS = false;
|
||||||
|
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||||
|
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
|
||||||
|
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||||
|
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||||
|
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||||
|
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||||
|
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||||
|
|
||||||
|
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||||
|
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||||
|
function askToken() {
|
||||||
|
if (!status) return;
|
||||||
|
status.className = 'err';
|
||||||
|
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||||
|
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||||
|
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||||
|
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||||
|
var go = document.getElementById('tkGo');
|
||||||
|
if (go) go.addEventListener('click', function () {
|
||||||
|
var v = (document.getElementById('tkIn').value || '').trim();
|
||||||
|
if (!v) return;
|
||||||
|
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||||
|
mount(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||||
|
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||||
|
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||||
|
mounted element does not cover shows it through, because every layer above body is
|
||||||
|
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||||
|
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||||
|
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||||
|
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
|
||||||
|
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
|
||||||
|
function done() {
|
||||||
|
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||||
|
document.documentElement.style.background = '#f3f3f1';
|
||||||
|
document.body.style.background = '#f3f3f1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(token) {
|
||||||
|
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||||
|
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
|
||||||
|
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
|
||||||
|
try {
|
||||||
|
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||||
|
var el = document.createElement("wall-of-people-groups");
|
||||||
|
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||||
|
slot.innerHTML = '';
|
||||||
|
slot.appendChild(el);
|
||||||
|
done();
|
||||||
|
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function qp(name) {
|
||||||
|
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||||
|
catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToken(data) {
|
||||||
|
if (typeof data === 'string') return data.trim();
|
||||||
|
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||||
|
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||||
|
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||||
|
var stored = '';
|
||||||
|
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||||
|
var urlTk = qp('tk');
|
||||||
|
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||||
|
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||||
|
if (TOKEN_FREE) {
|
||||||
|
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
|
||||||
|
mount('');
|
||||||
|
// A token-free composite with no token anywhere: its content renders, but its nested maps
|
||||||
|
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
|
||||||
|
if (NESTS_MAPS && !directToken) {
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.id = 'status';
|
||||||
|
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
status = bar;
|
||||||
|
askToken();
|
||||||
|
var goBtn = document.getElementById('tkGo');
|
||||||
|
if (goBtn) goBtn.addEventListener('click', function () {
|
||||||
|
var v = (document.getElementById('tkIn').value || '').trim();
|
||||||
|
if (v) { location.reload(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (directToken) { return mount(directToken); }
|
||||||
|
|
||||||
|
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||||
|
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||||
|
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||||
|
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||||
|
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||||
|
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||||
|
var candidates = [];
|
||||||
|
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||||
|
addCandidate(window.MAP_TOKEN_URL);
|
||||||
|
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||||
|
addCandidate(DEFAULT_TOKEN_URL);
|
||||||
|
|
||||||
|
function fetchToken(i, errors) {
|
||||||
|
if (i >= candidates.length) {
|
||||||
|
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||||
|
askToken();
|
||||||
|
if (status) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||||
|
status.insertBefore(d, status.firstChild);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var url = candidates[i];
|
||||||
|
return fetch(url)
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||||
|
var ct = r.headers.get('content-type') || '';
|
||||||
|
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
var tk = extractToken(data);
|
||||||
|
if (!tk) throw new Error(url + ' → no token in response');
|
||||||
|
mount(tk);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
errors.push(e && e.message ? e.message : String(e));
|
||||||
|
return fetchToken(i + 1, errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fetchToken(0, []);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue