Skip to content

Commit

Permalink
project page for community
Browse files Browse the repository at this point in the history
  • Loading branch information
nighca committed Oct 18, 2024
1 parent 189181c commit e343b19
Show file tree
Hide file tree
Showing 41 changed files with 1,155 additions and 152 deletions.
4 changes: 4 additions & 0 deletions spx-gui/src/apis/common/casdoor-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export class CasdoorClient {
}
if (resp.status === 204) return null
const body = await resp.json()
// Casdoor may return error with HTTP status 2xx, we need to check `status` field in body
if (body.status === 'error') {
throw new CasdoorApiException(body.msg)
}
return body.data
})

Expand Down
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>
52 changes: 52 additions & 0 deletions spx-gui/src/components/community/project/ReleaseHistory.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<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: '加载发布历史失败' }
)
</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 | null
}>()
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

0 comments on commit e343b19

Please sign in to comment.