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

Contestの詳細ページの作成 #51

Merged
merged 13 commits into from
Mar 28, 2023
48 changes: 48 additions & 0 deletions src/components/Contest/ContestTeamItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script lang="ts" setup>
import UserIcons from '/@/components/UI/UserIcons.vue'
import type { ContestTeam } from '/@/lib/apis'

interface Props {
contestId: string
contestTeam: ContestTeam
}

defineProps<Props>()
/* todo:サーバーからmembersが返ってくるようになったらcontestTeam.members.map(member=>member.id)を使う */
const userIds = ['sapphi_red', 'toshi00', 'tesso', 'mehm8128']
</script>

<template>
<router-link
:to="`/contests/${contestId}/teams/${contestTeam.id}/edit`"
:class="$style.link"
>
<div :class="$style.container">
<div>
<p :class="$style.name">{{ contestTeam.name }}</p>
<p :class="$style.result">{{ contestTeam.result }}</p>
</div>
<user-icons :user-ids="userIds" />
</div>
</router-link>
</template>

<style lang="scss" module>
.link {
color: inherit;
text-decoration: none;
}
.container {
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.name {
color: $color-primary;
font-size: 1.125rem;
}
.result {
margin-top: 2rem;
}
</style>
94 changes: 94 additions & 0 deletions src/components/Contest/ContestTeams.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script lang="ts" setup>
import BaseButton from '/@/components/UI/BaseButton.vue'

import FormInput from '/@/components/UI/FormInput.vue'
import ContestTeamItem from '/@/components/Contest/ContestTeamItem.vue'
import { RouterLink } from 'vue-router'
import { ContestTeam } from '/@/lib/apis'
import { ref } from 'vue'

interface Props {
contestId: string
contestTeams: ContestTeam[]
}

defineProps<Props>()
const emit = defineEmits<{
(e: 'input', value: string): void
}>()

const searchQuery = ref('')
</script>

<template>
<div>
<div :class="$style.searchFormContainer">
<div :class="$style.searchForm">
<p :class="$style.searchFormDescriptionText">検索</p>
<form-input
:model-value="searchQuery"
placeholder="チーム名"
icon="magnify"
@update:model-value="emit('input', $event)"
/>
</div>
<div :class="$style.newTeamLink">
<p :class="$style.searchFormDescriptionText">チーム作成</p>
<router-link
:to="`/contests/${contestId}/teams/new`"
:class="$style.link"
>
<base-button type="primary" icon="mdi:plus">New</base-button>
</router-link>
</div>
</div>
<ul :class="$style.teamList">
<li v-for="contestTeam in contestTeams" :key="contestTeam.id">
<contest-team-item
:contest-id="contestId"
:contest-team="contestTeam"
/>
</li>
</ul>
</div>
</template>

<style lang="scss" module>
.searchFormContainer {
display: flex;
align-items: center;
margin-top: 0.5rem;
}
.header {
margin: 4rem 0 2rem;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

使ってなさそう

.searchFormDescriptionText {
color: $color-secondary;
font-size: 0.875rem;
}
.searchForm {
flex-grow: 1;
}
.newTeamLink {
margin-left: 0.5rem;
}
.link {
text-decoration: none;
color: inherit;
}
.teamList {
list-style: none;
padding: 0.5rem 0;
li {
border: 1px solid $color-primary-text;
border-radius: 8px;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
&:hover {
background-color: $color-background-dim;
}
}
}
</style>
2 changes: 1 addition & 1 deletion src/components/Contests/ContestItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ defineProps<Props>()
</script>

<template>
<router-link :to="`/contests/${contest.id}/edit`" :class="$style.link">
<router-link :to="`/contests/${contest.id}`" :class="$style.link">
<div :class="$style.container">
<p :class="$style.name">{{ contest.name }}</p>
<p :class="$style.duration">
Expand Down
32 changes: 32 additions & 0 deletions src/components/UI/UserIcons.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts" setup>
import UserIcon from '/@/components/UI/UserIcon.vue'
interface Props {
userIds: string[]
}

defineProps<Props>()
</script>

<template>
<div :class="$style.userIcons">
<UserIcon
v-for="(userId, i) in userIds.slice(0, 3)"
:key="userId"
:user-id="userId"
:class="$style.userIcon"
:style="{ left: `${i * 16}px` }"
/>
<span v-if="userIds.length > 3">+{{ userIds.length - 3 }}</span>
</div>
</template>

<style lang="scss" module>
.userIcons {
width: 80px;
position: relative;
text-align: right;
}
.userIcon {
position: absolute;
}
</style>
128 changes: 113 additions & 15 deletions src/pages/Contest.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,118 @@
<script lang="ts" setup>
import ContentHeader from '/@/components/Layout/ContentHeader.vue'
import PageContainer from '/@/components/Layout/PageContainer.vue'
import BaseButton from '/@/components/UI/BaseButton.vue'

import apis, { ContestDetail, ContestTeam } from '/@/lib/apis'
import { RouterLink } from 'vue-router'
import { getDisplayDuration } from '/@/lib/date'
import useParam from '/@/use/param'
import useFetcher from '/@/use/fetcher'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exportしてる関数名と違って分かりづらいので、useDataFecher のほうがいいかも

import ContestTeamsComponent from '/@/components/Contest/ContestTeams.vue'

const contestId = useParam('id')
const { data: contest } = useFetcher<ContestDetail>(contestId, () =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

こんな感じに書いてもいいかも? fetcherState 使ってなさそうなので

const contest = ref<ContestDetail>()
 watchEffect(async () => {
      contest.value = (await apis.getContest(contestId.value)).data
    })

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetcherStateを使うことにしました

apis.getContest(contestId.value)
)
const { data: contestTeams } = useFetcher<ContestTeam[]>(contestId, () =>
apis.getContestTeams(contestId.value)
)

const searchContestTeams = (serachQuery: string) => {
// todo: serverでやるかも
contestTeams.value =
contestTeams.value?.filter(contestTeam => {
const regexp = new RegExp(serachQuery, 'i')
return regexp.test(contestTeam.name)
}) ?? []
}
</script>

<template>
<page-container>
<div>ContestDetail</div>
<div :class="$style.headerContainer">
<content-header
icon-name="mdi:trophy-outline"
:header-texts="[
{ title: 'Contests', url: '/contests' },
{ title: contest?.name ?? '', url: `/contests/${contestId}` }
]"
detail="コンテストの詳細です。"
:class="$style.header"
/>
<router-link :to="`/contests/${contestId}/edit`" :class="$style.link">
<base-button type="primary" icon="mdi:pencil">Edit</base-button>
</router-link>
</div>
<div v-if="contest !== undefined">
<section :class="$style.section">
<h2 :class="$style.h2">コンテスト名</h2>
<p :class="$style.content">{{ contest.name }}</p>
</section>
<section :class="$style.section">
<h2 :class="$style.h2">日時</h2>
<p :class="$style.content">
{{ getDisplayDuration(contest.duration) }}
</p>
</section>
<section :class="$style.section">
<h2 :class="$style.h2">リンク</h2>
<p :class="$style.content">
<a :href="contest.link">{{ contest.link }}</a>
</p>
</section>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://www.figma.com/file/BVnhB2JyTjKsGKdnAJDksb/traPortfolio?node-id=835%3A1018&t=svHVjSubEOdWX7bz-4

後出しでごめんなんだけど、リンクの部分のデザインを少し調整したのでお願いします。:pray:
image

<section :class="$style.section">
<h2 :class="$style.h2">説明</h2>
<p :class="$style.content">{{ contest.description }}</p>
</section>
<section :class="$style.section">
<h2 :class="$style.h2">チーム</h2>
<contest-teams-component
v-if="contestTeams !== undefined"
:class="$style.content"
:contest-id="contestId"
:contest-teams="contestTeams"
@input="searchContestTeams($event)"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

絞り込んだあとのチームを表示する責任があるのは、ContestTeams.vueだから、ContestTeams.vueに、絞り込む機能を実装したほうがもっとスッキリかけそう。

Contest.vueでは、全チームをpropsを通して ContestTeams.vueに流し込むだけで、 ContestTeams.vueでは、searchQueryを利用していい感じに絞り込むとかかな 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

サーバーで検索機能実装してもらえたら@input時にContest.vuesearchQueryを使ってContestTeamsをfetchしたいので、それを踏まえてContest.vueに書きました
検索機能をどっちで実装するかportfolioのチャンネルで相談してみます

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

そしたら、ContestTeamが検索するたびに表示されるチームが減るので、いい感じに動くようにしてほしい!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ほんとですね...
サーバーで検索機能ができるまでは上で言われたように、ContestTeams.vue側で検索機能を実装することにしました

/>
</section>
</div>
<router-link to="/contests" :class="$style.link">
<base-button
:class="$style.backButton"
type="secondary"
icon="mdi:arrow-left"
>
Back
</base-button>
</router-link>
</page-container>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import PageContainer from '../components/Layout/PageContainer.vue'

export default defineComponent({
name: 'Contest',
components: {
PageContainer
},
setup() {
return {}
}
})
</script>
<style lang="scss" module>
.headerContainer {
display: flex;
justify-content: space-between;
align-items: center;
}
.header {
margin: 4rem 0 2rem;
}
.link {
text-decoration: none;
color: inherit;
}
.section {
margin-bottom: 2rem;
}
.h2 {
font-weight: bold;
font-size: 20px;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

font-size : 1.25rem; がいいなー

}
.content {
margin-top: 0.5rem;
padding-left: 0.5rem;
}
.backButton {
margin-top: 2rem;
}
</style>
40 changes: 13 additions & 27 deletions src/use/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,25 @@
import { ref, Ref } from 'vue'
import { AxiosResponse } from 'axios'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetcher.tsは traPortfolio-UIから移植するときにコピーされたもので、Vuexの状態変化にしか使ってなさそうなので、元々のfetcher.tsなくてもいいかも。
userDataFetcher.tsとほとんど同じだから、userDataFetcher.tsの役割をこいつに寄せて大丈夫な感じがする。

Dashboard では、こいつを fetcher という名前で 運用しても悪くない感じがした。

import { ref, Ref, watchEffect } from 'vue'

export type FetcherState = 'loading' | 'loaded' | 'error'

/**
* @param value この値がnullでなくなったときに正常に取得できたとする
* @param fetchFunc 返り値がTである必要はないが基本的には一致する必要があるので指定してる。
* もし一致しない場合で利用する場合はas unknown as Tすること
*/
const useFetcher = <T>(
value: Readonly<Ref<T | null>>,
fetchFunc: () => Promise<T>
): { fetcherState: Ref<FetcherState> } => {
const isLoadedBeforeInit = value.value !== null
const state = ref<FetcherState>('loaded')

if (isLoadedBeforeInit) {
// 取得済み
return { fetcherState: state }
}

// キャッシュがなかったので取得する
;(async () => {
state.value = 'loading'
const useDataFetcher = <T>(
id: Ref<string>,
fetch: (id: string) => Promise<AxiosResponse<T>>
): { data: Ref<T | undefined>; fetcherState: Ref<FetcherState> } => {
const data = ref<T>()
const state = ref<FetcherState>('loading')
watchEffect(async () => {
try {
await fetchFunc()
data.value = (await fetch(id.value)).data
state.value = 'loaded'
} catch (e) {
state.value = 'error'

// eslint-disable-next-line no-console
console.error(e)
}
})()

return { fetcherState: state }
})
return { data: data, fetcherState: state }
}

export default useFetcher
export default useDataFetcher