Add journey sharing and collaboration
This commit is contained in:
parent
770ab0e5df
commit
c136f01f2e
@ -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']
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = `
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user