Skip to content

Commit

Permalink
feat: user profile page (#394)
Browse files Browse the repository at this point in the history
* fix: add user profile page to My sidebar

* fix(ui): update the user profile page to the new UI

* fix: use offchain network as user profile source of truth

* feat: add activities to user profile

* fix: add `avatar` to User

* chore: remove invalid import

* fix: fix font weight

* fix: style improvement

* feat: add edit profile modal

* fix: fix typing

* fix: account modal redirect to profile page instead of etherscan

* feat: send user update action to offchain network

* fix: fix network signature

* fix: support user not existing on offchain network

* fix: default username to resolve domains when not set

* feat: save user profile to offchain network

* fix: bust cache on avatar update

* fix: fix invalid condition

* fix: add share dropdown

* fix: page content should reflect url

* chore: fix function ordering

* feat: allow profile edition via alias

* fix: fix typing

* chore: reorder import

* chore : fix import

* fix: rename Profile to User

* refactor: code DRYing

* refactor: remove non-valid type property

* refactor: DRY-ing social networks links

* refactor: DRY-ing the share dropdown

* fix: add max length to input

* fix: keep same alignment as existing pages

* feat: add copy link to share dropdown

* fix: fix avatar rounding

* fix: make profile active only on own profile

* fix: refresh user activities on internal navigation

* fix: use username in page title

* fix: fix class being ignored

* fix: hide some menu for guest user

* fix: fix menu label

* fix: remove uneeded import

* fix: show original image until stamp support user cover

* refactor: avoid using extra params

* refactor: remove duplicate code

* chore: fix declaration order

* fix: return resolvedName from network api if available

* fix: follow User signature

* fix: make values optional

* fix: fix not used var

* fix: remove unecessary type

* refactor: code improvement

* refactor: pass user directly instead of pinning

* fix: improve typing

* fix: remove cache clearing, now delegated to backend

* feat: show username on topnav account menu when available

* fix: update User profile share message

* fix: fix empty nav sidebar appearing on all other pages

* fix: use UserCover image from stamp

* chore: update changelog

* fix: fix undefined user flashing

* refactor: rename component to follow convention

* refactor: improve aggregation

* fix: user helper function to return `cb`

* refactor: improve user fetching when not existing on backend

* refactor: DRY

* fix: fix UserCover not fetching usercover type

* chore: remove debug output

* chore: remove unused import

* fix: fix typing
  • Loading branch information
wa0x6e authored Jun 18, 2024
1 parent d5b9e9b commit bc7102e
Show file tree
Hide file tree
Showing 34 changed files with 758 additions and 213 deletions.
5 changes: 5 additions & 0 deletions .changeset/angry-birds-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@snapshot-labs/sx": patch
---

add updateUser to OffchainEthereumSig
88 changes: 55 additions & 33 deletions apps/ui/src/components/App/Nav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import IHStop from '~icons/heroicons-outline/stop';
import IHGlobe from '~icons/heroicons-outline/globe-americas';
import IHHome from '~icons/heroicons-outline/home';
import IHBell from '~icons/heroicons-outline/bell';
import { type FunctionalComponent } from 'vue';
type NavigationItem = {
name: string;
icon: FunctionalComponent;
count?: number;
hidden?: boolean;
link?: any;
active?: boolean;
};
const route = useRoute();
const uiStore = useUiStore();
Expand All @@ -33,7 +43,7 @@ const space = computed(() =>
const isController = computed(() =>
space.value ? compareAddresses(space.value.controller, web3.value.account) : false
);
const navigationConfig = computed(() => ({
const navigationConfig = computed<Record<string, Record<string, NavigationItem>>>(() => ({
space: {
overview: {
name: 'Overview',
Expand Down Expand Up @@ -85,7 +95,8 @@ const navigationConfig = computed(() => ({
my: {
home: {
name: 'Home',
icon: IHHome
icon: IHHome,
hidden: !web3.value.account
},
explore: {
name: 'Explore',
Expand All @@ -94,36 +105,56 @@ const navigationConfig = computed(() => ({
notifications: {
name: 'Notifications',
count: notificationsStore.unreadNotificationsCount,
icon: IHBell
icon: IHBell,
hidden: !web3.value.account
}
}
}));
const shortcuts = computed(() => {
const shortcuts = computed<Record<string, Record<string, NavigationItem>>>(() => {
return {
...(web3.value.account
? {
my: {
profile: {
name: 'Profile',
link: { name: 'user', params: { id: web3.value.account } },
icon: IHUser
},
settings: {
name: 'Settings',
link: { name: 'settings-spaces' },
icon: IHCog
}
}
}
: {})
my: {
user: {
name: 'Profile',
link: { name: 'user', params: { id: web3.value.account } },
icon: IHUser,
hidden: !web3.value.account,
active: (route.name as string) === 'user' && route.params.id === web3.value.account
},
settings: {
name: 'Settings',
link: { name: 'settings-spaces' },
icon: IHCog,
hidden: !web3.value.account,
active: false
}
}
};
});
const navigationItems = computed(() => navigationConfig.value[currentRouteName.value || '']);
const navigationItems = computed(() =>
Object.fromEntries(
Object.entries({
...navigationConfig.value[currentRouteName.value],
...shortcuts.value[currentRouteName.value]
})
.map(([key, item]): [string, NavigationItem] => {
return [
key,
{
...item,
active: item.active ?? route.name === `${currentRouteName.value}-${key}`,
hidden: item.hidden ?? false,
link: item.link ?? { name: `${currentRouteName.value}-${key}` }
}
];
})
.filter(([, item]) => item.hidden === false)
)
);
</script>

<template>
<div
v-if="navigationItems"
v-if="Object.keys(navigationItems).length"
class="lg:visible fixed w-[240px] border-r left-[72px] top-0 bottom-0 z-10 bg-skin-bg"
:class="{
invisible: !uiStore.sidebarOpen
Expand All @@ -134,9 +165,9 @@ const navigationItems = computed(() => navigationConfig.value[currentRouteName.v
<router-link
v-for="(item, key) in navigationItems"
:key="key"
:to="{ name: `${currentRouteName}-${key}` }"
:to="item.link"
class="px-4 py-1.5 space-x-2 flex items-center"
:class="route.name === `${currentRouteName}-${key}` ? 'text-skin-link' : 'text-skin-text'"
:class="item.active ? 'text-skin-link' : 'text-skin-text'"
>
<component :is="item.icon" class="inline-block"></component>
<span class="grow" v-text="item.name" />
Expand All @@ -146,15 +177,6 @@ const navigationItems = computed(() => navigationConfig.value[currentRouteName.v
v-text="item.count"
/>
</router-link>
<router-link
v-for="(item, key) in shortcuts[currentRouteName]"
:key="key"
:to="item.link"
class="px-4 py-1.5 space-x-2 flex items-center text-skin-text"
>
<component :is="item.icon" class="inline-block"></component>
<span v-text="item.name" />
</router-link>
</div>
</div>
</template>
68 changes: 68 additions & 0 deletions apps/ui/src/components/DropdownShare.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup lang="ts">
defineProps<{ message: string }>();
const uiStore = useUiStore();
const { copy } = useClipboard();
function handleCopyLinkClick() {
copy(window.location.href);
uiStore.addNotification('success', 'Link copied.');
}
</script>

<template>
<UiDropdown>
<template #button>
<slot name="button">
<UiButton v-bind="$attrs">
<IH-share class="inline-block" />
</UiButton>
</slot>
</template>
<template #items>
<UiDropdownItem v-slot="{ active }">
<a
class="flex items-center gap-2"
:class="{ 'opacity-80': active }"
@click="handleCopyLinkClick"
>
<IH-link />
Copy link
</a>
</UiDropdownItem>
<UiDropdownItem v-slot="{ active }">
<a
class="flex items-center gap-2"
:class="{ 'opacity-80': active }"
:href="`https://twitter.com/intent/tweet/?text=${message}`"
target="_blank"
>
<IC-x />
Share on X
</a>
</UiDropdownItem>
<UiDropdownItem v-slot="{ active }">
<a
class="flex items-center gap-2"
:class="{ 'opacity-80': active }"
:href="`https://hey.xyz/?hashtags=Snapshot&text=${message}`"
target="_blank"
>
<IC-lens />
Share on Lens
</a>
</UiDropdownItem>
<UiDropdownItem v-slot="{ active }">
<a
class="flex items-center gap-2"
:class="{ 'opacity-80': active }"
:href="`https://warpcast.com/~/compose?text=${message}`"
target="_blank"
>
<IC-farcaster />
Share on Farcaster
</a>
</UiDropdownItem>
</template>
</UiDropdown>
</template>
21 changes: 13 additions & 8 deletions apps/ui/src/components/Modal/Account.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { getInjected } from '@snapshot-labs/lock/src/utils';
import { shorten, explorerUrl } from '@/helpers/utils';
import connectors, { mapConnectorId, getConnectorIconUrl } from '@/helpers/connectors';
const win = window;
Expand Down Expand Up @@ -77,14 +76,20 @@ watch(open, () => (step.value = null));
</div>
<div v-else>
<div class="m-4 space-y-2">
<a :href="explorerUrl(web3.network.key, web3.account)" target="_blank" class="block">
<UiButton class="button-outline w-full flex justify-center items-center">
<UiStamp :id="web3.account" :size="18" class="mr-2 -ml-1" />
<span v-text="web3.name || shorten(web3.account)" />
<IH-arrow-sm-right class="inline-block ml-1 -rotate-45" />
<router-link
:to="{ name: 'user', params: { id: web3.account } }"
class="block"
tabindex="-1"
>
<UiButton
class="button-outline w-full flex justify-center items-center space-x-2"
@click="emit('close')"
>
<UiStamp :id="web3.account" :size="18" />
<span>My profile</span>
</UiButton>
</a>
<router-link to="/settings" class="block">
</router-link>
<router-link to="/settings" class="block" tabindex="-1">
<UiButton
class="button-outline w-full flex justify-center items-center"
@click="emit('close')"
Expand Down
108 changes: 108 additions & 0 deletions apps/ui/src/components/Modal/EditUser.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { clone } from '@/helpers/utils';
import { validateForm } from '@/helpers/validation';
import { User } from '@/types';
const props = defineProps<{
open: boolean;
user: User;
}>();
const emit = defineEmits<{
(e: 'close');
}>();
const definition = {
type: 'object',
title: 'User',
additionalProperties: false,
required: [],
properties: {
avatar: {
type: 'string',
format: 'stamp',
title: 'Avatar',
default: props.user.id
},
name: {
type: 'string',
title: 'Name',
minLength: 1,
maxLength: 32,
examples: ['Display name']
},
about: {
type: 'string',
format: 'long',
title: 'About',
maxLength: 256,
examples: ['Tell your story']
},
github: {
type: 'string',
format: 'github-handle',
title: 'GitHub',
maxLength: 39,
examples: ['GitHub handle']
},
twitter: {
type: 'string',
format: 'twitter-handle',
title: 'X (Twitter)',
maxLength: 15,
examples: ['X (Twitter) handle']
}
}
};
const actions = useActions();
const usersStore = useUsersStore();
const form = ref<Record<string, any>>(clone(props.user));
const sending = ref(false);
const formErrors = computed(() =>
validateForm(definition, form.value, { skipEmptyOptionalFields: true })
);
async function handleSubmit() {
sending.value = true;
try {
if (await actions.updateUser(form.value as User)) {
usersStore.fetchUser(props.user.id, true);
emit('close');
}
} finally {
sending.value = false;
}
}
</script>

<template>
<UiModal :open="open" data-model="user-modal" @close="$emit('close')">
<template #header>
<h3>Edit profile</h3>
</template>
<UiInputStampCover v-model="(form as any).cover" :user="user" />
<div class="s-box p-4 -mt-[80px]">
<UiForm v-model="form" :error="formErrors" :definition="definition" />
</div>
<template #footer>
<UiButton
class="w-full"
:disabled="Object.keys(formErrors).length > 0"
:loading="sending"
@click="handleSubmit"
>
Save
</UiButton>
</template>
</UiModal>
</template>

<style>
[data-model='user-modal'] [path='avatar'] {
@apply rounded-full;
}
</style>
5 changes: 1 addition & 4 deletions apps/ui/src/components/SpaceCover.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script setup lang="ts">
import sha3 from 'js-sha3';
import { offchainNetworks } from '@/networks';
import { getCacheHash, getStampUrl } from '@/helpers/utils';
import { NetworkID } from '@/types';
Expand All @@ -15,9 +14,7 @@ const props = withDefaults(
const width = props.size === 'sm' ? 450 : 1500;
const height = props.size === 'sm' ? 120 : 400;
const cb = computed(() =>
props.space.cover ? sha3.sha3_256(props.space.cover).slice(0, 16) : undefined
);
const cb = computed(() => getCacheHash(props.space.cover));
</script>

<template>
Expand Down
Loading

0 comments on commit bc7102e

Please sign in to comment.