Browse Source

feat: add profile page

and link to it whenever the UserHeader is displayed.

closes issue:
nostr:nevent1qqsyxwvgdaacc3hcd9rjwmjwqsfpu8guhrj37026ntl3aqw02mrprsgpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3ql5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqseukj7u

and partially fulfills issue:
nostr:nevent1qqsyxwvgdaacc3hcd9rjwmjwqsfpu8guhrj37026ntl3aqw02mrprsgpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3ql5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqseukj7u
master
DanConwayDev 2 years ago
parent
commit
63c74cb9a4
No known key found for this signature in database
GPG Key ID: 68E15486D73F75E1
  1. 45
      src/lib/components/CopyField.svelte
  2. 14
      src/lib/components/icons.ts
  3. 34
      src/lib/components/repo/RepoDetails.svelte
  4. 80
      src/lib/components/users/UserHeader.svelte
  5. 66
      src/routes/p/[npub]/+page.svelte
  6. 7
      src/routes/p/[npub]/+page.ts

45
src/lib/components/CopyField.svelte

@ -4,13 +4,23 @@
export let label: string = '' export let label: string = ''
export let content: string = '' export let content: string = ''
export let border_color = 'primary' export let border_color = 'primary'
export let no_border = false
export let icon: undefined | string[] = undefined
export let truncate: undefined | [number, number] = undefined
const truncatedContent = () => {
if (truncate && content.length > truncate[0] + truncate[1] + 3) {
return `${content.substring(0, truncate[0])}...${content.substring(content.length - 1 - truncate[1])}`
}
return content
}
let copied = false let copied = false
</script> </script>
<!-- eslint-disable-next-line svelte/valid-compile --> <!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="group mt-3 cursor-pointer" class="group cursor-pointer"
class:mt-3={!no_border}
on:click={async () => { on:click={async () => {
try { try {
await navigator.clipboard.writeText(content) await navigator.clipboard.writeText(content)
@ -21,16 +31,35 @@
} catch {} } catch {}
}} }}
> >
{label} {#if label.length > 0}
{#if copied}<span class="text-sm text-success opacity-50"> {label}
(copied to clipboard)</span {#if copied}<span class="text-sm text-success opacity-50">
>{/if} (copied to clipboard)</span
>{/if}
{/if}
<div <div
class="items mt-1 flex w-full items-center rounded-lg border border-{border_color} p-3 opacity-50" class="items flex w-full items-center rounded-lg border border-{border_color} opacity-50"
class:mt-1={no_border && label.length === 0}
class:border={!no_border}
class:p-3={!no_border}
class:text-success={copied} class:text-success={copied}
> >
<div class="flex-auto truncate text-sm"> {#if icon}<svg
{content} xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mr-1 mt-1 inline h-4 w-4 flex-none fill-base-content opacity-50"
class:fill-success={copied}
>
{#each icon as d}
<path {d} />
{/each}
</svg>{/if}
<div
class="truncate text-sm"
class:flex-auto={!no_border}
class:flex-none={no_border}
>
{truncatedContent()}
</div> </div>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

14
src/lib/components/icons.ts

@ -10,4 +10,18 @@ export const icons_misc = {
'M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z', 'M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z',
'M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z', 'M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z',
], ],
// https://icon-sets.iconify.design/octicon/key-16/ MIT licence
key: [
'M10.5 0a5.499 5.499 0 1 1-1.288 10.848l-.932.932a.75.75 0 0 1-.53.22H7v.75a.75.75 0 0 1-.22.53l-.5.5a.75.75 0 0 1-.53.22H5v.75a.75.75 0 0 1-.22.53l-.5.5a.75.75 0 0 1-.53.22h-2A1.75 1.75 0 0 1 0 14.25v-2c0-.199.079-.389.22-.53l4.932-4.932A5.5 5.5 0 0 1 10.5 0m-4 5.5c-.001.431.069.86.205 1.269a.75.75 0 0 1-.181.768L1.5 12.56v1.69c0 .138.112.25.25.25h1.69l.06-.06v-1.19a.75.75 0 0 1 .75-.75h1.19l.06-.06v-1.19a.75.75 0 0 1 .75-.75h1.19l1.023-1.025a.75.75 0 0 1 .768-.18A4 4 0 1 0 6.5 5.5M11 6a1 1 0 1 1 0-2a1 1 0 0 1 0 2',
],
// https://icon-sets.iconify.design/clarity/lightning-solid/ MIT licence
lightning: [
'M5.52.359A.5.5 0 0 1 6 0h4a.5.5 0 0 1 .474.658L8.694 6H12.5a.5.5 0 0 1 .395.807l-7 9a.5.5 0 0 1-.873-.454L6.823 9.5H3.5a.5.5 0 0 1-.48-.641z',
],
info: [
'M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-6.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 0 0 0-13M6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75M8 6a1 1 0 1 1 0-2a1 1 0 0 1 0 2',
],
link: [
'm7.775 3.275l1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0a.75.75 0 0 1 .018-1.042a.75.75 0 0 1 1.042-.018a2 2 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.75.75 0 0 1-1.042-.018a.75.75 0 0 1-.018-1.042m-4.69 9.64a2 2 0 0 0 2.83 0l1.25-1.25a.75.75 0 0 1 1.042.018a.75.75 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0a.75.75 0 0 1-.018 1.042a.75.75 0 0 1-1.042.018a2 2 0 0 0-2.83 0l-2.5 2.5a2 2 0 0 0 0 2.83',
],
} }

34
src/lib/components/repo/RepoDetails.svelte

@ -172,38 +172,8 @@
>{/if} >{/if}
</h4> </h4>
{#each maintainers as maintainer} {#each maintainers as maintainer}
<!-- eslint-disable-next-line svelte/valid-compile --> <div class="my-2 mt-3 break-words text-xs">
<!-- svelte-ignore a11y-click-events-have-key-events --> <UserHeader user={maintainer} />
<div
on:click={async () => {
try {
await navigator.clipboard.writeText(
new NDKUser({ hexpubkey: maintainer }).npub
)
maintainer_copied = maintainer
setTimeout(() => {
maintainer_copied = false
}, 2000)
} catch {}
}}
class="group my-2 mt-3 flex cursor-pointer items-center break-words text-xs"
class:text-success={maintainer_copied === maintainer}
class:opacity-50={maintainer_copied === maintainer}
>
<div class="flex-none"><UserHeader user={maintainer} /></div>
<div class="flex-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class=" ml-2 inline h-4 w-4 flex-none fill-base-content align-middle opacity-0 group-hover:opacity-100"
class:fill-base-content={maintainer_copied !== maintainer}
class:fill-success={maintainer_copied === maintainer}
>
{#each icons_misc.copy as d}
<path {d} />
{/each}
</svg>
</div>
</div> </div>
{/each} {/each}
{/if} {/if}

80
src/lib/components/users/UserHeader.svelte

@ -3,15 +3,20 @@
import type { Unsubscriber } from 'svelte/store' import type { Unsubscriber } from 'svelte/store'
import { defaults, getName, type User, type UserObject } from './type' import { defaults, getName, type User, type UserObject } from './type'
import { onDestroy } from 'svelte' import { onDestroy } from 'svelte'
import { goto } from '$app/navigation'
import ParsedContent from '../events/content/ParsedContent.svelte'
import CopyField from '../CopyField.svelte'
import { icons_misc } from '../icons'
export let user: User = { export let user: User = {
...defaults, ...defaults,
} }
export let inline = false export let inline = false
export let size: 'xs' | 'sm' | 'md' = 'md' export let size: 'xs' | 'sm' | 'md' | 'full' = 'md'
export let avatar_only = false export let avatar_only = false
export let in_event_header = false export let in_event_header = false
export let link_to_profile = true
let user_object: UserObject = { let user_object: UserObject = {
...defaults, ...defaults,
@ -32,7 +37,14 @@
$: display_name = getName(user_object) $: display_name = getName(user_object)
</script> </script>
<div class:inline-block={inline}> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class:inline-block={inline}
class:cursor-pointer={link_to_profile}
on:click={() => {
if (link_to_profile) goto(`/p/${user_object.npub}`)
}}
>
<div <div
class:my-2={!inline} class:my-2={!inline}
class:text-xs={size === 'xs'} class:text-xs={size === 'xs'}
@ -50,6 +62,8 @@
> >
<div <div
class:inline-block={inline} class:inline-block={inline}
class:h-32={!inline && size === 'full'}
class:w-32={!inline && size === 'full'}
class:h-8={!inline && size === 'md'} class:h-8={!inline && size === 'md'}
class:w-8={!inline && size === 'md'} class:w-8={!inline && size === 'md'}
class:h-4={!inline && size === 'sm'} class:h-4={!inline && size === 'sm'}
@ -68,6 +82,9 @@
</div> </div>
</div> </div>
<div <div
class:text-xl={size === 'full'}
class:width-max-prose={size === 'full'}
class:pl-4={!inline && size === 'full'}
class:pl-3={!inline && size === 'md'} class:pl-3={!inline && size === 'md'}
class:pl-2={!inline && (size === 'sm' || size === 'xs')} class:pl-2={!inline && (size === 'sm' || size === 'xs')}
class:pl-0={inline} class:pl-0={inline}
@ -75,7 +92,6 @@
class:m-auto={!inline} class:m-auto={!inline}
class:inline-block={inline} class:inline-block={inline}
class:hidden={avatar_only} class:hidden={avatar_only}
class:font-bold={in_event_header}
class:opacity-40={in_event_header} class:opacity-40={in_event_header}
> >
{#if loading} {#if loading}
@ -86,7 +102,63 @@
class:h-2.5={size === 'xs'} class:h-2.5={size === 'xs'}
></div> ></div>
{:else} {:else}
{display_name} <span class:font-bold={in_event_header || size === 'full'}
>{display_name}</span
>
{/if}
{#if size === 'full'}
<CopyField
icon={icons_misc.key}
content={user_object.npub}
no_border
truncate={[10, 10]}
/>
{#if profile && profile.lud16}
<CopyField
icon={icons_misc.lightning}
content={profile.lud16}
no_border
/>
{/if}
{#if profile && profile.website}
<a
href={profile.website}
target="_blank"
class="items items-top mt-1 flex w-full opacity-60"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mr-1 inline h-4 w-4 flex-none fill-base-content opacity-50"
>
{#each icons_misc.link as d}
<path {d} />
{/each}
</svg>
<div class="link-secondary text-sm">{profile.website}</div>
</a>
{/if}
{#if size === 'full' && profile && profile.about}
<div class="items items-top flex max-w-md opacity-60">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mr-1 mt-1 inline h-4 w-4 flex-none fill-base-content opacity-50"
>
{#each icons_misc.info as d}
<path {d} />
{/each}
</svg>
{#if loading}
<div class="w.max-lg skeleton h-3"></div>
{:else}
<div class="text-sm">
<ParsedContent content={profile?.about} />
</div>
{/if}
</div>
{/if}
{/if} {/if}
</div> </div>
</div> </div>

66
src/routes/p/[npub]/+page.svelte

@ -0,0 +1,66 @@
<script lang="ts">
import { nip19 } from 'nostr-tools'
import Container from '$lib/components/Container.svelte'
import ReposSummaryList from '$lib/components/ReposSummaryList.svelte'
import {
ensureRecentReposEvents,
recent_repo_summaries,
recent_repo_summaries_loading,
} from '$lib/stores/repos'
import UserHeader from '$lib/components/users/UserHeader.svelte'
export let data: { npub: string }
let error = false
let pubkey: undefined | string
$: {
try {
let decoded = nip19.decode(data.npub)
if (decoded.type === 'npub') pubkey = decoded.data
else if (decoded.type === 'nprofile') pubkey = decoded.data.pubkey
else error = true
} catch {
error = true
}
}
ensureRecentReposEvents()
</script>
{#if error}
<Container>
<div
role="alert"
class="wrap alert alert-error m-auto mt-6 w-full max-w-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<span
>Error! profile reference in URL is not a valid npub or nprofile: {data.npub}</span
>
</div>
</Container>
{:else if pubkey}
<Container>
<div class="mt-12">
<UserHeader user={pubkey} link_to_profile={false} size="full" />
<div class="divider"></div>
<ReposSummaryList
title="Repositories"
repos={$recent_repo_summaries.filter(
(summary) => pubkey && summary.maintainers.includes(pubkey)
)}
loading={$recent_repo_summaries_loading}
/>
</div>
</Container>
{/if}

7
src/routes/p/[npub]/+page.ts

@ -0,0 +1,7 @@
export const load = ({ params }: { params: { npub: string } }) => {
return {
npub: params.npub,
}
}
export const ssr = false
Loading…
Cancel
Save