Add journey sharing and collaboration

This commit is contained in:
André Rüegger 2026-06-01 08:04:32 +02:00
parent 770ab0e5df
commit c136f01f2e
4 changed files with 262 additions and 17 deletions

View File

@ -67,6 +67,27 @@ def get_user_by_id(user_id):
return next((u for u in users if u["id"] == user_id), None)
def public_user(user):
return {
"id": user["id"],
"username": user["username"],
}
def normalize_user_ids(user_ids):
users = load_users()
valid_ids = {user["id"] for user in users}
normalized = []
for user_id in user_ids or []:
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
if user_id in valid_ids and user_id not in normalized:
normalized.append(user_id)
return normalized
def allowed_image_file(filename):
if "." not in filename:
return False
@ -142,6 +163,19 @@ def me():
return jsonify({"error": "User not found"}), 401
return jsonify({"id": user["id"], "username": user["username"]})
@app.route("/api/users", methods=["GET"])
def get_users():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
current_user_id = get_current_user_id()
users = [
public_user(user)
for user in load_users()
if user["id"] != current_user_id
]
return jsonify(users)
# ==================== Journey helper functions ====================
def load_all_journeys():
@ -176,6 +210,8 @@ def user_can_view_journey(journey, user_id):
return True
if journey.get('visibility') == 'shared' and user_id in journey.get('shared_read', []):
return True
if journey.get('visibility') == 'shared' and user_id in journey.get('shared_edit', []):
return True
return False
def user_can_edit_journey(journey, user_id):
@ -226,8 +262,8 @@ def create_journey():
'markers': data.get('markers', []),
'created_at': datetime.now().isoformat(),
'visibility': data.get('visibility', 'private'),
'shared_read': data.get('shared_read', []),
'shared_edit': data.get('shared_edit', []),
'shared_read': normalize_user_ids(data.get('shared_read', [])),
'shared_edit': normalize_user_ids(data.get('shared_edit', [])),
'comments': data.get('comments', [])
}
@ -266,12 +302,16 @@ def update_journey(journey_id):
journey['description'] = data['description']
if 'markers' in data:
journey['markers'] = data['markers']
if 'visibility' in data:
journey['visibility'] = data['visibility']
if 'shared_read' in data:
journey['shared_read'] = data['shared_read']
if 'shared_edit' in data:
journey['shared_edit'] = data['shared_edit']
sharing_fields = {'visibility', 'shared_read', 'shared_edit'}
if sharing_fields.intersection(data.keys()):
if journey['owner_id'] != user_id:
return jsonify({'error': 'Only the owner can update sharing settings'}), 403
if 'visibility' in data:
journey['visibility'] = data['visibility']
if 'shared_read' in data:
journey['shared_read'] = normalize_user_ids(data['shared_read'])
if 'shared_edit' in data:
journey['shared_edit'] = normalize_user_ids(data['shared_edit'])
if 'comments' in data:
journey['comments'] = data ['comments']

View File

@ -217,6 +217,17 @@
.filter-btn:hover {
background: var(--surface-4);
}
.badge {
display: inline-flex;
align-items: center;
margin-left: var(--size-2);
padding: 2px var(--size-2);
border-radius: var(--radius-2);
background: var(--indigo-1);
color: var(--indigo-8);
font-size: var(--font-size-0);
font-weight: var(--font-weight-6);
}
</style>
</head>
<body>
@ -227,6 +238,7 @@
<div class="filter-tabs">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="own">My Posts</button>
<button class="filter-btn" data-filter="shared">Shared With Me</button>
<button class="filter-btn" data-filter="public">Public Posts</button>
</div>
<nav class="site-nav">
@ -287,19 +299,32 @@
<div class="post-card-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(journey.created_at).toLocaleDateString()}
${journey.markers ? `<span style="margin-left: 12px;"><i class="fas fa-map-marker-alt"></i> ${journey.markers.length} chapters</span>` : ''}
${journey.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
${getJourneyBadges(journey)}
</div>
<div class="post-card-excerpt">${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}</div>
</div>
</article>
`).join('');
}
function getJourneyBadges(journey) {
const badges = [];
const isOwner = journey.owner_id === currentUser?.id;
const isShared = journey.visibility === 'shared' && !isOwner;
if (journey.visibility === 'public') badges.push('Public');
if (isShared) badges.push(journey.can_edit ? 'Shared edit' : 'Shared');
return badges.map((badge) => `<span class="badge">${badge}</span>`).join('');
}
function filterJourneys(filter) {
if (filter === 'all') {
renderJourneys(allJourneys);
} else if (filter === 'own') {
renderJourneys(allJourneys.filter(j => j.owner_id === currentUser?.id));
} else if (filter === 'shared') {
renderJourneys(allJourneys.filter(j => j.owner_id !== currentUser?.id && j.visibility === 'shared'));
} else if (filter === 'public') {
renderJourneys(allJourneys.filter(j => j.visibility === 'public'));
}
@ -322,4 +347,4 @@
});
</script>
</body>
</html>
</html>

View File

@ -110,6 +110,17 @@
border-bottom: 1px solid var(--surface-4);
padding-bottom: var(--size-2);
}
.badge {
display: inline-flex;
align-items: center;
margin-left: var(--size-2);
padding: 2px var(--size-2);
border-radius: var(--radius-2);
background: var(--indigo-1);
color: var(--indigo-8);
font-size: var(--font-size-0);
font-weight: var(--font-weight-6);
}
.post-image {
max-width: 100%;
border-radius: var(--radius-2);
@ -326,7 +337,6 @@
const container = document.getElementById('post-content');
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
const canEdit = canEditJourney();
const canComment = currentJourney.visibility === 'public' || isOwner;
// Build chapters from markers
let chaptersHtml = '';
@ -363,14 +373,15 @@
<div class="post-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(currentJourney.created_at).toLocaleDateString()}
${currentJourney.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
${currentJourney.visibility === 'shared' && !isOwner ? `<span class="badge">${canEdit ? 'Shared edit' : 'Shared'}</span>` : ''}
</div>
${currentJourney.image ? `<img class="post-image" src="${currentJourney.image}" alt="${currentJourney.title}">` : ''}
<div class="post-description">${escapeHtml(currentJourney.description).replace(/\n/g, '<br>')}</div>
${chaptersHtml}
${isOwner ? `
${canEdit || isOwner ? `
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">
<a href="journey-edit.html?id=${currentJourney.id}" class="btn btn-sm"><i class="fas fa-edit"></i> Edit</a>
<button id="delete-journey-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i> Delete</button>
${canEdit ? `<a href="journey-edit.html?id=${currentJourney.id}" class="btn btn-sm"><i class="fas fa-edit"></i> Edit</a>` : ''}
${isOwner ? `<button id="delete-journey-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i> Delete</button>` : ''}
</div>
` : ''}
</article>
@ -398,6 +409,17 @@
&& currentJourney.shared_edit.includes(currentUser.id);
}
function canViewJourney() {
if (!currentUser || !currentJourney) return false;
if (currentUser.id === currentJourney.owner_id) return true;
if (currentJourney.visibility === 'public') return true;
return currentJourney.visibility === 'shared'
&& (
(Array.isArray(currentJourney.shared_read) && currentJourney.shared_read.includes(currentUser.id))
|| (Array.isArray(currentJourney.shared_edit) && currentJourney.shared_edit.includes(currentUser.id))
);
}
function getMarkerImages(marker) {
return marker.images || (marker.image ? [marker.image] : []);
}
@ -551,7 +573,7 @@
function renderComments(comments) {
const container = document.getElementById('comments-section');
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
const canComment = currentJourney.visibility === 'public' || isOwner;
const canComment = canViewJourney();
if (!comments.length) {
container.innerHTML = `

View File

@ -113,6 +113,52 @@
.form-group {
margin-bottom: var(--size-4);
}
.sharing-panel {
display: none;
background: var(--surface-2);
border: 1px solid var(--surface-4);
border-radius: var(--radius-2);
padding: var(--size-4);
margin-bottom: var(--size-4);
}
.sharing-panel.visible {
display: block;
}
.sharing-panel h3 {
margin-top: 0;
margin-bottom: var(--size-2);
font-size: var(--font-size-3);
}
.sharing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--size-4);
}
.sharing-user-list {
display: grid;
gap: var(--size-2);
}
.sharing-user {
display: flex;
align-items: center;
gap: var(--size-2);
padding: var(--size-2);
border-radius: var(--radius-2);
background: var(--surface-1);
}
.sharing-user input {
width: auto;
}
.sharing-help {
color: var(--gray-6);
font-size: var(--font-size-1);
margin-top: 0;
}
.empty-share-list {
color: var(--gray-6);
font-style: italic;
margin: 0;
}
label {
display: block;
margin-bottom: var(--size-2);
@ -302,6 +348,20 @@
<option value="shared">Shared (with specific users)</option>
</select>
</div>
<div id="sharing-panel" class="sharing-panel">
<h3><i class="fas fa-user-group"></i> Share With Users</h3>
<p class="sharing-help">Viewers can open the journey. Editors can also update its chapters, markers, and images.</p>
<div class="sharing-grid">
<div>
<label>Can View</label>
<div id="shared-read-users" class="sharing-user-list"></div>
</div>
<div>
<label>Can Edit</label>
<div id="shared-edit-users" class="sharing-user-list"></div>
</div>
</div>
</div>
<h3><i class="fas fa-map-marker-alt"></i> Chapters (Markers)</h3>
<div id="markers-container"></div>
@ -323,7 +383,11 @@
<script>
// ==================== GLOBALS ====================
let currentJourneyId = null;
let currentJourneyOwnerId = null;
let markersData = [];
let availableUsers = [];
let sharedReadIds = [];
let sharedEditIds = [];
const urlParams = new URLSearchParams(window.location.search);
const journeyId = urlParams.get('id');
@ -359,6 +423,85 @@
return { ...marker, images };
}
function canManageSharing() {
return !currentJourneyOwnerId || currentJourneyOwnerId === currentUser?.id;
}
function uniqueIds(ids) {
return [...new Set((ids || []).map((id) => parseInt(id)).filter(Number.isInteger))];
}
async function loadUsers() {
try {
const res = await fetch(`${API_BASE}/users`, { credentials: 'include' });
if (!res.ok) throw new Error('Could not load users');
availableUsers = await res.json();
} catch (err) {
availableUsers = [];
showToast('Sharing users could not be loaded', true);
}
}
function getUserCheckboxHtml(user, group, checked, disabled) {
return `
<label class="sharing-user">
<input type="checkbox" class="${group}-user" value="${user.id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${escapeHtml(user.username)}</span>
</label>
`;
}
function renderSharingControls() {
const panel = document.getElementById('sharing-panel');
const visibilitySelect = document.getElementById('journey-visibility');
const visibility = visibilitySelect.value;
const readContainer = document.getElementById('shared-read-users');
const editContainer = document.getElementById('shared-edit-users');
const isShared = visibility === 'shared';
const disabled = !canManageSharing();
visibilitySelect.disabled = disabled;
panel.classList.toggle('visible', isShared);
if (!isShared) return;
if (!availableUsers.length) {
readContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
editContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
return;
}
readContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-read', sharedReadIds.includes(user.id), disabled)
).join('');
editContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-edit', sharedEditIds.includes(user.id), disabled)
).join('');
document.querySelectorAll('.shared-read-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
document.querySelectorAll('.shared-edit-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
}
function updateSharedUsersFromForm() {
sharedReadIds = uniqueIds([...document.querySelectorAll('.shared-read-user:checked')].map((input) => input.value));
sharedEditIds = uniqueIds([...document.querySelectorAll('.shared-edit-user:checked')].map((input) => input.value));
sharedReadIds = uniqueIds([...sharedReadIds, ...sharedEditIds]);
renderSharingControls();
}
function getSharingPayload(visibility) {
if (visibility !== 'shared') {
return { shared_read: [], shared_edit: [] };
}
return {
shared_read: uniqueIds([...sharedReadIds, ...sharedEditIds]),
shared_edit: uniqueIds(sharedEditIds)
};
}
function renderMarkerImages(marker, idx) {
const images = marker.images || [];
if (!images.length) {
@ -509,11 +652,15 @@
const journey = await res.json();
console.log('Journey data:', journey);
currentJourneyId = journey.id;
currentJourneyOwnerId = journey.owner_id;
document.getElementById('journey-title').value = journey.title;
document.getElementById('journey-description').value = journey.description || '';
document.getElementById('journey-visibility').value = journey.visibility || 'private';
sharedReadIds = uniqueIds(journey.shared_read || []);
sharedEditIds = uniqueIds(journey.shared_edit || []);
markersData = (journey.markers || []).map(normalizeMarkerImages);
console.log('Markers data loaded:', markersData);
renderSharingControls();
renderMarkers();
document.getElementById('form-title').textContent = 'Edit Journey';
} catch (err) {
@ -547,7 +694,11 @@
videoUrl: m.videoUrl || ''
}));
const payload = { title, description, markers, visibility };
const payload = { title, description, markers };
if (canManageSharing()) {
payload.visibility = visibility;
Object.assign(payload, getSharingPayload(visibility));
}
console.log('Saving payload:', payload);
try {
@ -601,11 +752,17 @@
updateUserMenu();
if (!isNew && journeyId) {
loadJourney(journeyId);
await loadUsers();
await loadJourney(journeyId);
} else if (isNew) {
currentJourneyId = null;
currentJourneyOwnerId = currentUser.id;
document.getElementById('form-title').textContent = 'New Journey';
markersData = [];
sharedReadIds = [];
sharedEditIds = [];
await loadUsers();
renderSharingControls();
renderMarkers();
} else {
window.location.href = 'blog-list.html';
@ -613,6 +770,7 @@
document.getElementById('journey-form').addEventListener('submit', saveJourney);
document.getElementById('add-marker').addEventListener('click', addMarker);
document.getElementById('journey-visibility').addEventListener('change', renderSharingControls);
});
</script>
</body>