Compare commits
3 commits
b9bca36b50
...
79be46c33d
| Author | SHA1 | Date | |
|---|---|---|---|
| 79be46c33d | |||
| e540feba44 | |||
| 2b376bb244 |
8 changed files with 100 additions and 84 deletions
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
||||
|
|
@ -7,6 +7,7 @@ import AppShell from '@/components/layout/AppShell.vue'
|
|||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
||||
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
||||
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
||||
|
|
@ -16,26 +17,7 @@ const route = useRoute()
|
|||
const { t } = useI18n()
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const activitiesStore = useActivitiesStore()
|
||||
|
||||
// Probe LNbits admin status + extension auto_approve once at auth-ready
|
||||
// so the shell-mounted dialog renders the right warning copy when an
|
||||
// owner edits their own event.
|
||||
const isAdmin = ref(false)
|
||||
const autoApprove = ref(false)
|
||||
async function probeApprovalState() {
|
||||
if (!isAuthenticated.value) return
|
||||
const wallet = currentUser.value?.wallets?.[0]
|
||||
if (!wallet?.inkey) return
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey)
|
||||
if (wallet.adminkey) {
|
||||
isAdmin.value = await ticketApi.isAdmin(wallet.adminkey)
|
||||
}
|
||||
}
|
||||
onMounted(probeApprovalState)
|
||||
watch(isAuthenticated, (yes) => {
|
||||
if (yes) probeApprovalState()
|
||||
})
|
||||
const { isAdmin, autoApprove } = useApprovalState()
|
||||
|
||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
||||
|
|
@ -47,7 +29,12 @@ const tabs = computed<BottomTab[]>(() => [
|
|||
{
|
||||
name: t('activities.createNew'),
|
||||
icon: Plus,
|
||||
onClick: () => { activitiesStore.showCreateDialog = true },
|
||||
onClick: () => {
|
||||
// Defensively clear any lingering edit selection so the Create
|
||||
// tap always opens in Create mode regardless of a prior Edit.
|
||||
activitiesStore.editingEvent = null
|
||||
activitiesStore.showCreateDialog = true
|
||||
},
|
||||
disabled: !isAuthenticated.value,
|
||||
},
|
||||
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
|
|
@ -171,7 +171,7 @@ const availableCurrencies = ref<string[]>(['sat'])
|
|||
const loadingCurrencies = ref(false)
|
||||
const selectedCategories = ref<string[]>([])
|
||||
|
||||
function populateFromEvent(event: TicketedEvent) {
|
||||
async function populateFromEvent(event: TicketedEvent) {
|
||||
isPopulating.value = true
|
||||
const start = splitDateTime(event.event_start_date)
|
||||
const end = splitDateTime(event.event_end_date)
|
||||
|
|
@ -189,25 +189,24 @@ function populateFromEvent(event: TicketedEvent) {
|
|||
})
|
||||
selectedCategories.value = [...(event.categories ?? [])]
|
||||
if (event.banner) {
|
||||
// Mirror the URL-to-alias bridge from market's CreateProductDialog
|
||||
// so the <ImageUpload> renders the existing banner via its pict-rs
|
||||
// file ID. delete_token is unknown for already-uploaded images, so
|
||||
// removal just clears the slot client-side.
|
||||
const url = event.banner
|
||||
const alias = url.includes('/image/original/')
|
||||
? url.split('/image/original/')[1]
|
||||
: url
|
||||
// Re-render the stored banner via its pict-rs file ID. delete_token
|
||||
// is intentionally empty: we don't own the original upload's token
|
||||
// and removing the image on the client should NOT delete the
|
||||
// server-side file (it may be the user changing their mind about
|
||||
// re-using it, or the same image referenced elsewhere).
|
||||
const alias = imageService.extractFileId(event.banner)
|
||||
bannerImages.value = [
|
||||
{ alias, isPrimary: true, delete_token: '', details: {} as any },
|
||||
]
|
||||
} else {
|
||||
bannerImages.value = []
|
||||
}
|
||||
// Release the watcher guard on the next tick so vee-validate's batched
|
||||
// updates settle before user input can drive the auto-mirror.
|
||||
setTimeout(() => {
|
||||
isPopulating.value = false
|
||||
}, 0)
|
||||
// Release the watcher guard after Vue's microtask queue drains so
|
||||
// vee-validate's batched setValues lands before user input can drive
|
||||
// the auto-mirror. nextTick is more reliable than setTimeout(0) here
|
||||
// — it waits for the DOM tick *after* all current microtasks.
|
||||
await nextTick()
|
||||
isPopulating.value = false
|
||||
}
|
||||
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
|
|
@ -223,7 +222,7 @@ watch(() => props.open, async (isOpen) => {
|
|||
}
|
||||
}
|
||||
if (props.event) {
|
||||
populateFromEvent(props.event)
|
||||
await populateFromEvent(props.event)
|
||||
}
|
||||
} else {
|
||||
selectedCategories.value = []
|
||||
|
|
|
|||
49
src/modules/activities/composables/useApprovalState.ts
Normal file
49
src/modules/activities/composables/useApprovalState.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
|
||||
/**
|
||||
* Probe the events extension for the caller's approval-flow context:
|
||||
*
|
||||
* - `autoApprove` — is the global `auto_approve` toggle on? Reads the
|
||||
* invoice-key-gated public probe (v1.3.0-aio.5+), so non-admin
|
||||
* wallet holders get an accurate answer.
|
||||
* - `isAdmin` — is the caller an LNbits admin? Tries the admin-only
|
||||
* `/events/all` endpoint; a 200 means yes.
|
||||
*
|
||||
* Both refs default to `false` (the safer assumption for warning UI
|
||||
* — biased toward showing the "edit will go back to pending" copy
|
||||
* when in doubt). Probe re-runs whenever auth flips to authenticated.
|
||||
*
|
||||
* Used by every surface that opens the edit-mode CreateEventDialog
|
||||
* (activities-app/App.vue shell mount, activities EventsPage). Keeps
|
||||
* the probe logic single-source-of-truth.
|
||||
*/
|
||||
export function useApprovalState() {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const isAdmin = ref(false)
|
||||
const autoApprove = ref(false)
|
||||
|
||||
async function probe() {
|
||||
if (!isAuthenticated.value) {
|
||||
isAdmin.value = false
|
||||
autoApprove.value = false
|
||||
return
|
||||
}
|
||||
const wallet = currentUser.value?.wallets?.[0]
|
||||
if (!wallet?.inkey) return
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey)
|
||||
if (wallet.adminkey) {
|
||||
isAdmin.value = await ticketApi.isAdmin(wallet.adminkey)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(probe)
|
||||
watch(isAuthenticated, (yes) => {
|
||||
if (yes) probe()
|
||||
})
|
||||
|
||||
return { isAdmin, autoApprove, probe }
|
||||
}
|
||||
|
|
@ -29,9 +29,12 @@ export function useEvents() {
|
|||
const seen = new Set(publicEvents.map((e) => e.id))
|
||||
const own = myEvents.filter((e) => !seen.has(e.id))
|
||||
return [...publicEvents, ...own]
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Falling back to just the public feed is acceptable — the user
|
||||
// can still browse, they just won't see their own pending events.
|
||||
// Log so a flaky probe is debuggable from the console without
|
||||
// toast-spamming the user on every transient failure.
|
||||
console.warn('[useEvents] fetchMyEvents failed, showing public feed only:', err)
|
||||
return publicEvents
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useApprovalState } from '../composables/useApprovalState'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
|
@ -18,6 +19,7 @@ import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
|||
|
||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
||||
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
||||
const { isAdmin, autoApprove } = useApprovalState()
|
||||
|
||||
const showPurchaseDialog = ref(false)
|
||||
const selectedEvent = ref<{
|
||||
|
|
@ -31,13 +33,6 @@ const showEventDialog = ref(false)
|
|||
// `null` ↔ create mode; populated ↔ edit mode.
|
||||
const editingEvent = ref<TicketedEvent | null>(null)
|
||||
|
||||
// Probe once at mount so the dialog can render the "going to pending"
|
||||
// warning accurately. Both probes degrade to safe defaults on failure
|
||||
// (not admin, not auto-approved), which biases the warning toward
|
||||
// being shown when in doubt.
|
||||
const isAdmin = ref(false)
|
||||
const autoApprove = ref(false)
|
||||
|
||||
const myWalletIds = computed(
|
||||
() => new Set((currentUser.value?.wallets ?? []).map((w) => w.id))
|
||||
)
|
||||
|
|
@ -46,17 +41,6 @@ function canEdit(event: TicketedEvent): boolean {
|
|||
return isAuthenticated.value && myWalletIds.value.has(event.wallet)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isAuthenticated.value) return
|
||||
const wallet = currentUser.value?.wallets?.[0]
|
||||
if (!wallet?.inkey) return
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey)
|
||||
if (wallet.adminkey) {
|
||||
isAdmin.value = await ticketApi.isAdmin(wallet.adminkey)
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr: string | null | undefined) {
|
||||
if (!dateStr) return 'Date not available'
|
||||
const date = new Date(dateStr)
|
||||
|
|
|
|||
|
|
@ -449,7 +449,11 @@ const removeImage = async (imageToRemove: ImageWithMetadata) => {
|
|||
if (props.disabled) return
|
||||
|
||||
try {
|
||||
// Only try to delete from pict-rs if we have a delete token (newly uploaded images)
|
||||
// Server-side delete only when we have a delete_token (newly
|
||||
// uploaded this session). Pre-existing images re-populated from a
|
||||
// stored URL ship `delete_token: ''` by convention — we don't own
|
||||
// the original upload's one-time token, and removing on the client
|
||||
// shouldn't reach back and wipe the server-side file.
|
||||
if (imageToRemove.delete_token) {
|
||||
await imageService.deleteImage(imageToRemove.delete_token, imageToRemove.alias)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,9 +300,12 @@ export class ImageUploadService extends BaseService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Extract file ID from alias, handling both file IDs and full URLs
|
||||
* Extract a pict-rs file ID from an alias, accepting both bare IDs
|
||||
* and full `/image/original/<id>` URLs. Public so callers
|
||||
* re-populating uploads from stored URLs (edit flows) don't have to
|
||||
* re-implement the parse.
|
||||
*/
|
||||
private extractFileId(alias: string): string {
|
||||
extractFileId(alias: string): string {
|
||||
if (!alias) {
|
||||
return ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -516,30 +516,17 @@ watch(() => props.isOpen, async (isOpen) => {
|
|||
// Reset form with appropriate initial values
|
||||
resetForm({ values: initialValues })
|
||||
|
||||
// Convert existing image URLs to the format expected by ImageUpload component
|
||||
// Convert existing image URLs to the format expected by ImageUpload.
|
||||
// delete_token is intentionally empty for pre-existing images: see
|
||||
// ImageUploadService.deleteImage gate — removing on the client
|
||||
// should not delete the server-side file.
|
||||
if (props.product?.images && props.product.images.length > 0) {
|
||||
// For existing products, we need to convert URLs back to a format ImageUpload can display
|
||||
uploadedImages.value = props.product.images.map((url, index) => {
|
||||
let alias = url
|
||||
|
||||
// If it's a full pict-rs URL, extract just the file ID
|
||||
if (url.includes('/image/original/')) {
|
||||
const parts = url.split('/image/original/')
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
alias = parts[1]
|
||||
}
|
||||
} else if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
// Keep full URLs as-is
|
||||
alias = url
|
||||
}
|
||||
|
||||
return {
|
||||
alias: alias,
|
||||
delete_token: '',
|
||||
isPrimary: index === 0,
|
||||
details: {}
|
||||
}
|
||||
})
|
||||
uploadedImages.value = props.product.images.map((url, index) => ({
|
||||
alias: imageService.extractFileId(url),
|
||||
delete_token: '',
|
||||
isPrimary: index === 0,
|
||||
details: {} as any,
|
||||
}))
|
||||
} else {
|
||||
uploadedImages.value = []
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue