feat(activities): UI tweaks across feed, detail, hosting, calendar, scan, shell #91
2 changed files with 208 additions and 122 deletions
refactor(sidebar): show identity inline, move edit form into gear popup
Read-only identity (avatar, display name, @username, copyable Lightning address, copyable npub) stays visible in the sheet so the user can grab their address without opening any form. The full edit form moves behind a gear-icon dialog. Log out stays in the sheet footer so signing out doesn't require opening the popup. Lightning Address and NIP-05 share the same `username@domain` in this stack — the @username row above the card already signals NIP-05, so the copyable address row is labeled just "Lightning". The picture-upload row now stacks the preview above the upload widget on narrow viewports so the Gallery/Camera buttons stay inside the sheet/dialog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit
00f6a99f92
|
|
@ -1,38 +1,122 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Home, LogIn } from 'lucide-vue-next'
|
||||
import { Check, Copy, Home, LogIn, LogOut, Settings } from 'lucide-vue-next'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||
import { toastService } from '@/core/services/ToastService'
|
||||
import PreferencesRow from './PreferencesRow.vue'
|
||||
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, user } = useAuth()
|
||||
const { isAuthenticated, user, logout } = useAuth()
|
||||
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||
|
||||
const npubPreview = computed(() => {
|
||||
const lightningDomain = computed(
|
||||
() => import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname,
|
||||
)
|
||||
|
||||
// Lightning Address and NIP-05 share the same identifier in this app —
|
||||
// both are `username@domain`. The `@username` row above the identity
|
||||
// card already signals NIP-05, so this row is labeled just "Lightning".
|
||||
const lightningAddress = computed(() => {
|
||||
const username = user.value?.username
|
||||
if (!username) return ''
|
||||
return `${username}@${lightningDomain.value}`
|
||||
})
|
||||
|
||||
const npub = computed(() => {
|
||||
const pubkey = user.value?.pubkey
|
||||
if (!pubkey) return ''
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`
|
||||
try {
|
||||
return nip19.npubEncode(pubkey)
|
||||
} catch {
|
||||
return pubkey
|
||||
}
|
||||
})
|
||||
|
||||
const npubPreview = computed(() => {
|
||||
const value = npub.value
|
||||
if (!value) return ''
|
||||
return value.length > 24 ? `${value.slice(0, 12)}…${value.slice(-8)}` : value
|
||||
})
|
||||
|
||||
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
|
||||
|
||||
const copiedField = ref<string | null>(null)
|
||||
async function copyToClipboard(text: string, field: string) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copiedField.value = field
|
||||
toastService.success(t('common.nav.copied', 'Copied to clipboard'))
|
||||
setTimeout(() => {
|
||||
if (copiedField.value === field) copiedField.value = null
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err)
|
||||
toastService.error(t('common.nav.copyFailed', 'Failed to copy'))
|
||||
}
|
||||
}
|
||||
|
||||
const editProfileOpen = ref(false)
|
||||
|
||||
function goLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout()
|
||||
toastService.success(t('common.nav.loggedOut', 'Logged out'))
|
||||
router.push('/login')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to log out'
|
||||
toastService.error(`${t('common.nav.logoutFailed', 'Logout failed')}: ${msg}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SheetHeader>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||
<Button
|
||||
v-if="isAuthenticated"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
||||
@click="editProfileOpen = true"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<SheetDescription v-if="isAuthenticated">
|
||||
{{ t('common.nav.profileDescription') }}
|
||||
</SheetDescription>
|
||||
|
|
@ -41,18 +125,60 @@ function goLogin() {
|
|||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<!-- Identity card (logged in) -->
|
||||
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
|
||||
<Avatar class="h-12 w-12">
|
||||
<!-- Identity card (logged in) — read-only summary. Editing happens
|
||||
through the gear button next to the title. -->
|
||||
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar class="h-12 w-12 shrink-0">
|
||||
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
|
||||
<p v-if="displayName && user?.username" class="text-xs text-muted-foreground truncate">
|
||||
@{{ user.username }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Address — this is also the NIP-05 in this stack, but
|
||||
the @username above already signals the NIP-05 to the reader. -->
|
||||
<button
|
||||
v-if="lightningAddress"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5 text-left hover:bg-background transition-colors min-w-0"
|
||||
:aria-label="t('common.nav.copyLightning', 'Copy Lightning address')"
|
||||
@click="copyToClipboard(lightningAddress, 'lightning')"
|
||||
>
|
||||
<span class="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
||||
⚡ Lightning
|
||||
</span>
|
||||
<span class="text-xs font-mono truncate flex-1 min-w-0">{{ lightningAddress }}</span>
|
||||
<component
|
||||
:is="copiedField === 'lightning' ? Check : Copy"
|
||||
class="w-3.5 h-3.5 text-muted-foreground shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- npub — copy full bech32 even though we display a preview. -->
|
||||
<button
|
||||
v-if="npub"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5 text-left hover:bg-background transition-colors min-w-0"
|
||||
:aria-label="t('common.nav.copyNpub', 'Copy npub')"
|
||||
@click="copyToClipboard(npub, 'npub')"
|
||||
>
|
||||
<span class="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
||||
npub
|
||||
</span>
|
||||
<span class="text-xs font-mono truncate flex-1 min-w-0">{{ npubPreview }}</span>
|
||||
<component
|
||||
:is="copiedField === 'npub' ? Check : Copy"
|
||||
class="w-3.5 h-3.5 text-muted-foreground shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
|
||||
<slot name="app-nav" />
|
||||
|
||||
|
|
@ -72,7 +198,7 @@ function goLogin() {
|
|||
<PreferencesRow layout="list" />
|
||||
</div>
|
||||
|
||||
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
|
||||
<!-- Logged-out: prominent log-in CTA -->
|
||||
<div v-if="!isAuthenticated" class="mt-6">
|
||||
<Separator class="mb-4" />
|
||||
<Button class="w-full" @click="goLogin">
|
||||
|
|
@ -81,9 +207,49 @@ function goLogin() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Logged-in: full profile management form -->
|
||||
<!-- Logged-in: log-out button stays visible without opening the edit popup. -->
|
||||
<div v-else class="mt-6">
|
||||
<Separator class="mb-4" />
|
||||
<ProfileSettings />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" class="w-full">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
{{ t('common.nav.logOut', 'Log out') }}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Log out of {{ user?.username || 'your account' }}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ t('common.nav.logOutConfirmDescription', "You'll need to sign in again to access your wallet, post in the forum, place orders, or use any feature that needs your account.") }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ t('common.nav.cancel', 'Cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
@click="onLogout"
|
||||
>
|
||||
{{ t('common.nav.logOut', 'Log out') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<!-- Edit-profile popup (gear icon) — the full form lives here so the
|
||||
sheet stays scannable. -->
|
||||
<Dialog v-model:open="editProfileOpen">
|
||||
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('common.nav.editProfile', 'Edit profile') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('common.nav.editProfileDescription', 'Update your display name and profile picture.') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ProfileSettings />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Profile Settings</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Manage your profile information and Nostr identity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<form @submit="onSubmit" class="space-y-6">
|
||||
<!-- Profile Picture -->
|
||||
<FormField name="picture">
|
||||
|
|
@ -17,22 +8,26 @@
|
|||
<FormDescription>
|
||||
Upload a profile picture. This will be published to your Nostr profile.
|
||||
</FormDescription>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Stack preview + upload on narrow viewports so the upload
|
||||
component's Gallery/Camera buttons don't push out of the
|
||||
sheet/dialog. -->
|
||||
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
<!-- Current picture preview -->
|
||||
<div v-if="currentPictureUrl" class="relative">
|
||||
<div v-if="currentPictureUrl" class="relative shrink-0">
|
||||
<img
|
||||
:src="currentPictureUrl"
|
||||
alt="Profile picture"
|
||||
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border">
|
||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border shrink-0">
|
||||
<User class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Upload component. Avatars are small; tighten the
|
||||
default compress knobs so a 4K phone photo lands as
|
||||
a ~200 KB 512px WebP. -->
|
||||
<div class="flex-1 min-w-0 w-full">
|
||||
<ImageUpload
|
||||
v-model="uploadedPicture"
|
||||
:multiple="false"
|
||||
|
|
@ -45,6 +40,7 @@
|
|||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -91,26 +87,6 @@
|
|||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Lightning Address / NIP-05 (read-only info) -->
|
||||
<div class="rounded-lg border p-4 space-y-2 bg-muted/50">
|
||||
<h4 class="text-sm font-medium">Nostr Identity</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Zap class="h-4 w-4 text-yellow-500" />
|
||||
<span class="text-muted-foreground">Lightning Address:</span>
|
||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ lightningAddress }}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Hash class="h-4 w-4 text-purple-500" />
|
||||
<span class="text-muted-foreground">NIP-05:</span>
|
||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ nip05Preview }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
These identifiers are automatically derived from your username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="updateError" class="text-sm text-destructive">
|
||||
{{ updateError }}
|
||||
|
|
@ -135,34 +111,6 @@
|
|||
Your profile is broadcast to Nostr automatically when you save changes.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" class="w-full">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Log out of {{ user?.username || 'your account' }}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'll need to sign in again to access your wallet, post in the
|
||||
forum, place orders, or use any feature that needs your account.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="onLogout" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Log out
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -173,8 +121,7 @@ import { toTypedSchema } from '@vee-validate/zod'
|
|||
import * as z from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { User, Zap, Hash } from 'lucide-vue-next'
|
||||
import { User } from 'lucide-vue-next'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
|
|
@ -184,27 +131,13 @@ import {
|
|||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
// Services
|
||||
const { user, updateProfile, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const { user, updateProfile } = useAuth()
|
||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||
const toast = useToast()
|
||||
|
||||
|
|
@ -224,14 +157,14 @@ const lightningDomain = computed(() =>
|
|||
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
|
||||
)
|
||||
|
||||
// Computed previews
|
||||
// Live preview of the user's NIP-05 / Lightning address — shown in the
|
||||
// username field's helper text so the consequence of a future rename is
|
||||
// visible inline.
|
||||
const nip05Preview = computed(() => {
|
||||
const username = form.values.username || currentUsername.value || 'username'
|
||||
return `${username}@${lightningDomain.value}`
|
||||
})
|
||||
|
||||
const lightningAddress = computed(() => nip05Preview.value)
|
||||
|
||||
// Form schema
|
||||
const profileFormSchema = toTypedSchema(z.object({
|
||||
username: z.string()
|
||||
|
|
@ -327,17 +260,4 @@ const updateUserProfile = async (formData: any) => {
|
|||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Log out + redirect to /login on this app's origin.
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
toast.success('Logged out')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to log out'
|
||||
console.error('Error logging out:', error)
|
||||
toast.error(`Logout failed: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue