Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project page for community #999

Merged
merged 3 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion spx-gui/src/apis/project-release.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { client, type FileCollection } from './common'
import { client, type ByPage, type FileCollection, type PaginationParams } from './common'

export type ProjectRelease = {
/** Unique identifier */
Expand Down Expand Up @@ -29,3 +29,13 @@ export type CreateReleaseParams = Pick<
export function createRelease(params: CreateReleaseParams) {
return client.post('/project-release', params) as Promise<ProjectRelease>
}

export type ListReleasesParams = PaginationParams & {
projectFullName?: string
orderBy?: 'createdAt' | 'updatedAt' | 'remixCount'
sortOrder?: 'asc' | 'desc'
}

export function listReleases(params: ListReleasesParams) {
return client.get(`/project-releases/list`, params) as Promise<ByPage<ProjectRelease>>
}
57 changes: 47 additions & 10 deletions spx-gui/src/apis/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,30 +46,38 @@ export type ProjectData = {
remixCount: number
}

export type AddProjectByRemixParams = Pick<ProjectData, 'name' | 'visibility'> & {
/** Full name of the project or project release to remix from. */
remixSource: string
}

export type AddProjectParams = Pick<ProjectData, 'name' | 'files' | 'visibility'>

export async function addProject(params: AddProjectParams, signal?: AbortSignal) {
export async function addProject(
params: AddProjectParams | AddProjectByRemixParams,
signal?: AbortSignal
) {
return client.post('/project', params, { signal }) as Promise<ProjectData>
}

export type UpdateProjectParams = Pick<ProjectData, 'files' | 'visibility'> &
Partial<Pick<ProjectData, 'description' | 'instructions' | 'thumbnail'>>

function encode(owner: string, name: string) {
return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
}

export async function updateProject(
owner: string,
name: string,
params: UpdateProjectParams,
signal?: AbortSignal
) {
return client.put(`/project/${encode(owner, name)}`, params, { signal }) as Promise<ProjectData>
return client.put(`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, params, {
signal
}) as Promise<ProjectData>
}

export function deleteProject(owner: string, name: string) {
return client.delete(`/project/${encode(owner, name)}`) as Promise<void>
return client.delete(
`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
) as Promise<void>
}

export type ListProjectParams = PaginationParams & {
Expand All @@ -78,6 +86,8 @@ export type ListProjectParams = PaginationParams & {
* Defaults to the authenticated user if not specified. Use * to include projects from all users.
**/
owner?: string
/** Filter remixed projects by the full name of the source project or project release */
remixedFrom?: string
/** Filter projects by name pattern */
keyword?: string
/** Filter projects by visibility */
Expand Down Expand Up @@ -108,8 +118,12 @@ export async function listProject(params?: ListProjectParams) {
return client.get('/projects/list', params) as Promise<ByPage<ProjectData>>
}

export async function getProject(owner: string, name: string) {
return client.get(`/project/${encode(owner, name)}`) as Promise<ProjectData>
export async function getProject(owner: string, name: string, signal?: AbortSignal) {
return client.get(
`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
undefined,
{ signal }
) as Promise<ProjectData>
}

export enum ExploreOrder {
Expand Down Expand Up @@ -161,7 +175,7 @@ export async function exploreProjects({ order, count }: ExploreParams) {
*/
export async function isLiking(owner: string, name: string) {
try {
await client.get(`/project/${encode(owner, name)}/liking`)
await client.get(`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/liking`)
return true
} catch (e) {
if (e instanceof ApiException) {
Expand All @@ -174,3 +188,26 @@ export async function isLiking(owner: string, name: string) {
throw e
}
}

export async function likeProject(owner: string, name: string) {
return client.post(
`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/liking`
) as Promise<void>
}

export async function unlikeProject(owner: string, name: string) {
return client.delete(
`/project/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/liking`
) as Promise<void>
}

export type RemixSource = [owner: string, project: string, release?: string]

export function parseRemixSource(rs: string) {
const [owner, project, release = null] = rs.split('/')
return { owner, project, release }
}

export function stringifyRemixSource(owner: string, project: string, release?: string) {
return [owner, project, release].filter((s) => s != null).join('/')
}
31 changes: 27 additions & 4 deletions spx-gui/src/components/community/CenteredWrapper.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
<template>
<section class="centered">
<section class="centered" :class="`size-${size}`">
<slot></slot>
</section>
</template>

<script setup lang="ts">
// different size of centered-content for different pages
type Size = 'medium' | 'large'

withDefaults(
defineProps<{
size?: Size
}>(),
{
size: 'medium'
}
)
</script>

<style scoped lang="scss">
@import '@/components/ui/responsive';

Expand All @@ -12,9 +26,18 @@
margin-left: auto;
margin-right: auto;

width: 1020px;
@include responsive(desktop-large) {
width: 1280px;
&.size-medium {
width: 988px;
@include responsive(desktop-large) {
width: 1240px;
}
}

&.size-large {
width: 1240px;
@include responsive(desktop-large) {
width: 1492px;
}
}
}
</style>
7 changes: 4 additions & 3 deletions spx-gui/src/components/community/ProjectsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<h2 class="title">
<slot name="title"></slot>
</h2>
<RouterLink class="link" :to="linkTo">
<RouterLink v-if="linkTo != null" class="link" :to="linkTo">
<slot name="link"></slot>
<UIIcon class="link-icon" type="arrowRightSmall" />
</RouterLink>
Expand All @@ -28,11 +28,12 @@ import { UIIcon } from '@/components/ui'
* Context (page) where the projects section is used
* - `home`: community home page
* - `user`: user overview page
* - `project`: project page
*/
type Context = 'home' | 'user'
type Context = 'home' | 'user' | 'project'

defineProps<{
linkTo: string
linkTo?: string
queryRet: QueryRet<unknown[]>
context: Context
}>()
Expand Down
56 changes: 56 additions & 0 deletions spx-gui/src/components/community/project/OwnerInfo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useAsyncComputed } from '@/utils/utils'
import { getUser } from '@/apis/user'
import UserLink from '../user/UserLink.vue'

const props = defineProps<{
owner: string
}>()

const user = useAsyncComputed(() => getUser(props.owner))
</script>

<template>
<UserLink class="owner-info" :user="user">
<i class="avatar" :style="user != null ? { backgroundImage: `url(${user.avatar})` } : null"></i>
{{ owner }}
</UserLink>
</template>

<style lang="scss" scoped>
.owner-info {
display: flex;
align-items: center;
gap: 4px;

color: var(--ui-color-title);
// TODO: extract to `@/components/ui/`?
text-decoration: underline;
transition: 0.1s;

&:hover {
color: var(--ui-color-primary-main);
.avatar {
border-color: var(--ui-color-primary-400);
}
}
&:active {
color: var(--ui-color-primary-600);
.avatar {
border-color: var(--ui-color-primary-600);
}
}
}

.avatar {
display: block;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--ui-color-grey-100);
background-color: var(--ui-color-grey-100);
background-position: center;
background-size: contain;
transition: 0.1s;
}
</style>
56 changes: 56 additions & 0 deletions spx-gui/src/components/community/project/ReleaseHistory.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useQuery } from '@/utils/exception'
import { humanizeTime } from '@/utils/utils'
import { listReleases } from '@/apis/project-release'
import { UITimeline, UITimelineItem, UILoading, UIError } from '@/components/ui'

const props = defineProps<{
owner: string
name: string
}>()

const {
data: releases,
isLoading,
error,
refetch
} = useQuery(
async () => {
const { owner, name } = props
const { data } = await listReleases({
projectFullName: `${owner}/${name}`,
orderBy: 'createdAt',
sortOrder: 'desc',
pageIndex: 1,
pageSize: 10 // load at most 10 recent releases
})
return data
},
{ en: 'Load release history failed', zh: '加载发布历史失败' }
)

defineExpose({
refetch
})
</script>

<template>
<UILoading v-if="isLoading" />
<UIError v-else-if="error != null" :retry="refetch">
{{ $t(error.userMessage) }}
</UIError>
<p v-else-if="releases?.length === 0">
{{ $t({ en: 'No release history yet', zh: '暂无发布历史' }) }}
</p>
<UITimeline v-else-if="releases != null">
<UITimelineItem
v-for="release in releases"
:key="release.id"
:time="$t(humanizeTime(release.createdAt))"
>
{{ release.description }}
</UITimelineItem>
</UITimeline>
</template>

<style lang="scss" scoped></style>
34 changes: 34 additions & 0 deletions spx-gui/src/components/community/project/RemixedFrom.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { parseRemixSource, stringifyRemixSource } from '@/apis/project'
import { getProjectPageRoute } from '@/router'

const props = defineProps<{
remixedFrom: string
}>()

const remixedFrom = computed(() => parseRemixSource(props.remixedFrom))
</script>

<template>
<p class="remixed-from">
{{ $t({ en: 'Remixed from', zh: '改编自' }) }}
<RouterLink class="link" :to="getProjectPageRoute(remixedFrom.owner, remixedFrom.project)">
{{ stringifyRemixSource(remixedFrom.owner, remixedFrom.project) }}
</RouterLink>
</p>
</template>

<style lang="scss" scoped>
.link {
// TODO: extract to `@/components/ui/`?
color: inherit;
text-decoration: underline;
&:hover {
color: var(--ui-color-primary-main);
}
&:active {
color: var(--ui-color-primary-600);
}
}
</style>
23 changes: 8 additions & 15 deletions spx-gui/src/components/community/user/UserAvatar.vue
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
<template>
<RouterLink
v-if="user != null"
:to="to"
<UserLink
class="user-avatar"
:style="{ backgroundImage: `url(${user.avatar})` }"
:title="user.displayName"
></RouterLink>
:style="userInfo != null ? { backgroundImage: `url(${userInfo.avatar})` } : null"
:user="userInfo"
></UserLink>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useAsyncComputed } from '@/utils/utils'
import { getUser, type User } from '@/apis/user'
import { getUserPageRoute } from '@/router'
import UserLink from './UserLink.vue'

const props = defineProps<{
owner: string | User
user: string | User
}>()

const username = computed(() =>
typeof props.owner === 'string' ? props.owner : props.owner.username
)
const to = computed(() => getUserPageRoute(username.value))
const user = useAsyncComputed(() =>
typeof props.owner === 'string' ? getUser(props.owner) : Promise.resolve(props.owner)
const userInfo = useAsyncComputed(() =>
typeof props.user === 'string' ? getUser(props.user) : Promise.resolve(props.user)
)
</script>

Expand Down
2 changes: 1 addition & 1 deletion spx-gui/src/components/community/user/UserHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const emit = defineEmits<{
updated: [User]
}>()

const isCurrentUser = computed(() => props.user.username === useUserStore().userInfo?.name)
const isCurrentUser = computed(() => props.user.username === useUserStore().userInfo()?.name)

const invokeEditProfileModal = useModal(EditProfileModal)

Expand Down
Loading
Loading