mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
Improve profile page look and feel.
This commit is contained in:
357
src/components/discover/AuthorProfileView.vue
Normal file
357
src/components/discover/AuthorProfileView.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col bg-comfy-menu-bg text-base-foreground">
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-4 border-b border-interface-stroke bg-comfy-menu-bg px-6 py-4 text-base-foreground"
|
||||
>
|
||||
<Button variant="secondary" size="md" @click="emit('back')">
|
||||
<i class="icon-[lucide--arrow-left]" />
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<div class="flex-1" />
|
||||
</div>
|
||||
|
||||
<section class="px-6 pt-6">
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-2xl border border-border-default bg-comfy-menu-secondary-bg p-6 shadow-sm text-base-foreground"
|
||||
>
|
||||
<div class="flex flex-wrap items-start gap-6">
|
||||
<img
|
||||
:src="authorAvatar"
|
||||
:alt="authorName"
|
||||
class="size-20 rounded-2xl bg-secondary-background object-cover ring-2 ring-border-subtle"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="text-xs font-semibold uppercase text-base-foreground/60"
|
||||
>
|
||||
{{ $t('discover.author.title') }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="truncate text-3xl font-semibold text-base-foreground">
|
||||
{{ authorName }}
|
||||
</h1>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-xs text-base-foreground/80"
|
||||
>
|
||||
<span class="text-base-foreground">
|
||||
#{{ $t('discover.author.rankValue') }}
|
||||
</span>
|
||||
<span class="text-base-foreground/60">
|
||||
{{ $t('discover.author.rankLabel') }}
|
||||
</span>
|
||||
<span class="text-base-foreground/60">•</span>
|
||||
<span class="text-base-foreground/60">
|
||||
{{ $t('discover.author.rankCaption') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 max-w-2xl text-sm text-base-foreground/80">
|
||||
{{ $t('discover.author.tagline') }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<div
|
||||
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
|
||||
>
|
||||
{{ $t('discover.author.badgeCreator') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
|
||||
>
|
||||
{{ $t('discover.author.badgeOpenSource') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-full border border-border-subtle bg-comfy-menu-bg px-3 py-1 text-base-foreground/80"
|
||||
>
|
||||
{{ $t('discover.author.badgeTemplates') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-3 md:w-64">
|
||||
<div
|
||||
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
|
||||
>
|
||||
<div class="text-xs uppercase text-base-foreground/60">
|
||||
{{ $t('discover.author.aboutTitle') }}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-foreground/80">
|
||||
{{ $t('discover.author.aboutDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
|
||||
>
|
||||
<div class="text-xs uppercase text-base-foreground/60">
|
||||
{{ $t('discover.author.runsLabel') }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-base-foreground">
|
||||
{{ formattedRuns }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-border-subtle bg-comfy-menu-bg p-4 text-base-foreground"
|
||||
>
|
||||
<div class="text-xs uppercase text-base-foreground/60">
|
||||
{{ $t('discover.author.copiesLabel') }}
|
||||
</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-base-foreground">
|
||||
{{ formattedCopies }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 bg-comfy-menu-bg">
|
||||
<div
|
||||
class="mx-auto mb-4 flex w-full max-w-5xl flex-wrap items-center gap-3 border-b border-interface-stroke pb-4 text-base-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--layout-grid] size-4 text-base-foreground/60"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-base-foreground">
|
||||
{{ $t('discover.author.workflowsTitle') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full bg-secondary-background px-3 py-1 text-xs"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-3.5" />
|
||||
{{
|
||||
$t(
|
||||
'discover.author.workflowsCount',
|
||||
{ count: totalWorkflows },
|
||||
totalWorkflows
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('discover.author.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="max-w-md flex-1"
|
||||
show-border
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4"
|
||||
>
|
||||
<CardContainer
|
||||
v-for="n in 12"
|
||||
:key="`author-skeleton-${n}`"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
class="hover:bg-base-background"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<div class="size-full animate-pulse bg-dialog-surface" />
|
||||
</CardTop>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="p-3">
|
||||
<div
|
||||
class="mb-2 h-5 w-3/4 animate-pulse rounded bg-dialog-surface"
|
||||
/>
|
||||
<div class="h-4 w-full animate-pulse rounded bg-dialog-surface" />
|
||||
</div>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="results && results.templates.length === 0"
|
||||
class="mx-auto flex h-64 w-full max-w-5xl flex-col items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--search] mb-4 size-12 opacity-50" />
|
||||
<p class="mb-2 text-lg">{{ $t('discover.author.noResults') }}</p>
|
||||
<p class="text-sm">{{ $t('discover.author.noResultsHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="results"
|
||||
class="mx-auto grid w-full max-w-5xl grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4"
|
||||
>
|
||||
<CardContainer
|
||||
v-for="template in results.templates"
|
||||
:key="template.objectID"
|
||||
size="compact"
|
||||
custom-aspect-ratio="2/3"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-base-background"
|
||||
@mouseenter="hoveredTemplate = template.objectID"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="emit('selectWorkflow', template)"
|
||||
>
|
||||
<template #top>
|
||||
<div class="shrink-0">
|
||||
<CardTop ratio="square">
|
||||
<LazyImage
|
||||
:src="template.thumbnail_url"
|
||||
:alt="template.title"
|
||||
class="size-full rounded-lg object-cover transition-transform duration-300"
|
||||
:class="hoveredTemplate === template.objectID && 'scale-105'"
|
||||
/>
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in template.tags.slice(0, 2)"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</template>
|
||||
</CardTop>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="flex flex-col gap-1.5 p-3">
|
||||
<h3
|
||||
class="line-clamp-1 text-sm font-medium"
|
||||
:title="template.title"
|
||||
>
|
||||
{{ template.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="line-clamp-2 text-xs text-muted-foreground"
|
||||
:title="template.description"
|
||||
>
|
||||
{{ template.description }}
|
||||
</p>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="model in template.models.slice(0, 2)"
|
||||
:key="model"
|
||||
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ model }}
|
||||
</span>
|
||||
<span
|
||||
v-if="template.models.length > 2"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
+{{ template.models.length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="results && results.totalPages > 1"
|
||||
class="flex shrink-0 items-center justify-center gap-2 border-t border-interface-stroke px-6 py-3"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:disabled="currentPage === 0"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left]" />
|
||||
</Button>
|
||||
<span class="px-4 text-sm text-muted-foreground">
|
||||
{{ currentPage + 1 }} / {{ results.totalPages }}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:disabled="currentPage >= results.totalPages - 1"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
|
||||
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
|
||||
|
||||
const { authorName, authorAvatarUrl, stats } = defineProps<{
|
||||
authorName: string
|
||||
authorAvatarUrl?: string
|
||||
stats?: { runs: number; copies: number }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
selectWorkflow: [workflow: AlgoliaWorkflowTemplate]
|
||||
}>()
|
||||
|
||||
const { search, isLoading, results } = useWorkflowTemplateSearch()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(0)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
|
||||
const authorAvatar = computed(
|
||||
() => authorAvatarUrl ?? '/assets/images/comfy-logo-single.svg'
|
||||
)
|
||||
|
||||
const formattedRuns = computed(() =>
|
||||
stats?.runs ? stats.runs.toLocaleString() : '--'
|
||||
)
|
||||
const formattedCopies = computed(() =>
|
||||
stats?.copies ? stats.copies.toLocaleString() : '--'
|
||||
)
|
||||
|
||||
const totalWorkflows = computed(() => results.value?.totalHits ?? 0)
|
||||
|
||||
const authorFacetFilters = computed(() => [[`author_name:${authorName}`]])
|
||||
|
||||
async function performSearch() {
|
||||
await search({
|
||||
query: searchQuery.value,
|
||||
pageSize: 24,
|
||||
pageNumber: currentPage.value,
|
||||
facetFilters: authorFacetFilters.value
|
||||
})
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
performSearch()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authorName,
|
||||
() => {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => searchQuery.value,
|
||||
() => {
|
||||
currentPage.value = 0
|
||||
performSearch()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
performSearch()
|
||||
})
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@
|
||||
@back="selectedWorkflow = null"
|
||||
@run-workflow="handleRunWorkflow"
|
||||
@make-copy="handleMakeCopy"
|
||||
@author-selected="handleAuthorSelected"
|
||||
/>
|
||||
<div v-else class="flex size-full flex-col">
|
||||
<!-- Header with search -->
|
||||
@@ -139,6 +140,7 @@
|
||||
v-for="template in results.templates"
|
||||
:key="template.objectID"
|
||||
size="compact"
|
||||
custom-aspect-ratio="2/3"
|
||||
variant="ghost"
|
||||
class="hover:bg-base-background"
|
||||
@mouseenter="hoveredTemplate = template.objectID"
|
||||
@@ -146,21 +148,23 @@
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<LazyImage
|
||||
:src="template.thumbnail_url"
|
||||
:alt="template.title"
|
||||
class="size-full object-cover transition-transform duration-300"
|
||||
:class="hoveredTemplate === template.objectID && 'scale-105'"
|
||||
/>
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in template.tags.slice(0, 2)"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
<div class="shrink-0">
|
||||
<CardTop ratio="square">
|
||||
<LazyImage
|
||||
:src="template.thumbnail_url"
|
||||
:alt="template.title"
|
||||
class="size-full rounded-lg object-cover transition-transform duration-300"
|
||||
:class="hoveredTemplate === template.objectID && 'scale-105'"
|
||||
/>
|
||||
</template>
|
||||
</CardTop>
|
||||
<template #bottom-right>
|
||||
<SquareChip
|
||||
v-for="tag in template.tags.slice(0, 2)"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</template>
|
||||
</CardTop>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<div class="flex flex-col gap-1.5 p-3">
|
||||
@@ -170,9 +174,11 @@
|
||||
>
|
||||
{{ template.title }}
|
||||
</h3>
|
||||
<div
|
||||
<button
|
||||
v-if="template.author_name"
|
||||
class="flex items-center gap-1.5"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded px-1 -mx-1 border border-transparent bg-transparent text-muted-foreground transition-colors hover:bg-secondary-background hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle"
|
||||
@click.stop="handleAuthorClick(template)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
@@ -185,7 +191,7 @@
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ template.author_name }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<p
|
||||
class="line-clamp-2 text-xs text-muted-foreground"
|
||||
:title="template.description"
|
||||
@@ -252,6 +258,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
@@ -263,6 +270,7 @@ import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSearch } from '@/composables/discover/useWorkflowTemplateSearch'
|
||||
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
|
||||
import { authorNameToSlug } from '@/utils/authorProfileUtil'
|
||||
|
||||
const { search, isLoading, results } = useWorkflowTemplateSearch()
|
||||
|
||||
@@ -270,6 +278,7 @@ const searchQuery = ref('')
|
||||
const currentPage = ref(0)
|
||||
const hoveredTemplate = ref<string | null>(null)
|
||||
const selectedWorkflow = ref<AlgoliaWorkflowTemplate | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const selectedTags = ref<Array<{ name: string; value: string }>>([])
|
||||
const selectedModels = ref<Array<{ name: string; value: string }>>([])
|
||||
@@ -341,6 +350,17 @@ function handleTemplateClick(template: AlgoliaWorkflowTemplate) {
|
||||
selectedWorkflow.value = template
|
||||
}
|
||||
|
||||
function handleAuthorClick(template: AlgoliaWorkflowTemplate) {
|
||||
if (!template.author_name) return
|
||||
const slug = authorNameToSlug(template.author_name)
|
||||
void router.push({ name: 'AuthorProfileView', params: { slug } })
|
||||
}
|
||||
|
||||
function handleAuthorSelected(author: { name: string; avatarUrl?: string }) {
|
||||
const slug = authorNameToSlug(author.name)
|
||||
void router.push({ name: 'AuthorProfileView', params: { slug } })
|
||||
}
|
||||
|
||||
function handleRunWorkflow(_workflow: AlgoliaWorkflowTemplate) {
|
||||
// TODO: Implement workflow run
|
||||
}
|
||||
|
||||
@@ -47,7 +47,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!hasAuthor"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-transparent p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-subtle',
|
||||
hasAuthor
|
||||
? 'hover:bg-secondary-background'
|
||||
: 'cursor-default opacity-80'
|
||||
)
|
||||
"
|
||||
@click="handleAuthorClick"
|
||||
>
|
||||
<img
|
||||
:src="authorAvatar"
|
||||
:alt="authorName"
|
||||
@@ -61,7 +73,7 @@
|
||||
{{ $t('discover.detail.officialWorkflow') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -182,6 +194,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useHomePanelStore } from '@/stores/workspace/homePanelStore'
|
||||
@@ -201,8 +214,11 @@ const emit = defineEmits<{
|
||||
back: []
|
||||
makeCopy: [workflow: AlgoliaWorkflowTemplate]
|
||||
appMode: [workflow: AlgoliaWorkflowTemplate]
|
||||
authorSelected: [author: { name: string; avatarUrl?: string }]
|
||||
}>()
|
||||
|
||||
const hasAuthor = computed(() => !!workflow.author_name)
|
||||
|
||||
const authorName = computed(
|
||||
() => workflow.author_name ?? t('discover.detail.author')
|
||||
)
|
||||
@@ -234,6 +250,14 @@ function handleRunWorkflow() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleAuthorClick = () => {
|
||||
if (!workflow.author_name) return
|
||||
emit('authorSelected', {
|
||||
name: workflow.author_name,
|
||||
avatarUrl: workflow.author_avatar_url ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
type WorkflowPayload = Record<string, unknown>
|
||||
|
||||
const setLinearModeInWorkflowData = (workflowData: unknown) => {
|
||||
|
||||
@@ -28,6 +28,25 @@
|
||||
"makeCopyFailed": "Failed to copy workflow: {error}",
|
||||
"appNotReady": "ComfyUI is still loading. Please wait and try again."
|
||||
},
|
||||
"author": {
|
||||
"title": "Creator",
|
||||
"searchPlaceholder": "Search this creator's workflows",
|
||||
"workflowsCount": "{count} workflow | {count} workflows",
|
||||
"runsLabel": "runs",
|
||||
"copiesLabel": "copies",
|
||||
"rankLabel": "rank",
|
||||
"rankValue": "31",
|
||||
"rankCaption": "Last 30 days",
|
||||
"workflowsTitle": "Workflows",
|
||||
"tagline": "Designing workflows that feel polished, practical, and production-ready.",
|
||||
"aboutTitle": "About",
|
||||
"aboutDescription": "Focused on building clean, reliable workflows with thoughtful defaults and clear documentation.",
|
||||
"badgeCreator": "Creator profile",
|
||||
"badgeOpenSource": "Open-source friendly",
|
||||
"badgeTemplates": "Workflow templates",
|
||||
"noResults": "No workflows found for this creator",
|
||||
"noResultsHint": "Try a different search or check back later."
|
||||
},
|
||||
"share": {
|
||||
"share": "Share",
|
||||
"copyFailed": "Failed to copy link to clipboard",
|
||||
|
||||
@@ -68,6 +68,11 @@ const router = createRouter({
|
||||
path: 'user-select',
|
||||
name: 'UserSelectView',
|
||||
component: () => import('@/views/UserSelectView.vue')
|
||||
},
|
||||
{
|
||||
path: 'profiles/:slug',
|
||||
name: 'AuthorProfileView',
|
||||
component: () => import('@/views/AuthorProfileView.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
46
src/utils/authorProfileUtil.ts
Normal file
46
src/utils/authorProfileUtil.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type AuthorProfileStats = {
|
||||
runs: number
|
||||
copies: number
|
||||
}
|
||||
|
||||
export type AuthorProfile = {
|
||||
slug: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
stats?: AuthorProfileStats
|
||||
}
|
||||
|
||||
const KNOWN_AUTHORS: Record<string, AuthorProfile> = {
|
||||
comfyorg: {
|
||||
slug: 'comfyorg',
|
||||
name: 'Comfy Org',
|
||||
avatarUrl: '/assets/images/comfy-logo-single.svg',
|
||||
stats: { runs: 128_400, copies: 4_260 }
|
||||
}
|
||||
}
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, '')
|
||||
.trim()
|
||||
|
||||
export const authorNameToSlug = (name: string) =>
|
||||
KNOWN_AUTHORS[slugify(name)]?.slug ?? slugify(name)
|
||||
|
||||
const titleize = (value: string) =>
|
||||
value
|
||||
.replaceAll(/[-_]+/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replaceAll(/\b\w/g, (char) => char.toUpperCase())
|
||||
|
||||
export const authorSlugToProfile = (slug: string): AuthorProfile => {
|
||||
const normalized = slugify(slug)
|
||||
return (
|
||||
KNOWN_AUTHORS[normalized] ?? {
|
||||
slug: normalized,
|
||||
name: titleize(slug)
|
||||
}
|
||||
)
|
||||
}
|
||||
67
src/views/AuthorProfileView.vue
Normal file
67
src/views/AuthorProfileView.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<WorkflowDetailView
|
||||
v-if="selectedWorkflow"
|
||||
:workflow="selectedWorkflow"
|
||||
@back="selectedWorkflow = null"
|
||||
@author-selected="handleAuthorSelected"
|
||||
@run-workflow="handleRunWorkflow"
|
||||
@make-copy="handleMakeCopy"
|
||||
/>
|
||||
<AuthorProfileView
|
||||
v-else
|
||||
:author-name="profile.name"
|
||||
:author-avatar-url="profile.avatarUrl"
|
||||
:stats="profile.stats"
|
||||
@back="handleBack"
|
||||
@select-workflow="handleAuthorWorkflowSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AuthorProfileView from '@/components/discover/AuthorProfileView.vue'
|
||||
import WorkflowDetailView from '@/components/discover/WorkflowDetailView.vue'
|
||||
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
|
||||
import {
|
||||
authorNameToSlug,
|
||||
authorSlugToProfile
|
||||
} from '@/utils/authorProfileUtil'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const selectedWorkflow = ref<AlgoliaWorkflowTemplate | null>(null)
|
||||
|
||||
const profile = computed(() =>
|
||||
authorSlugToProfile(String(route.params.slug ?? ''))
|
||||
)
|
||||
|
||||
const handleAuthorWorkflowSelect = (template: AlgoliaWorkflowTemplate) => {
|
||||
selectedWorkflow.value = template
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
void router.push({ name: 'GraphView' })
|
||||
}
|
||||
|
||||
const handleAuthorSelected = (author: { name: string; avatarUrl?: string }) => {
|
||||
const slug = authorNameToSlug(author.name)
|
||||
void router.push({ name: 'AuthorProfileView', params: { slug } })
|
||||
}
|
||||
|
||||
const handleRunWorkflow = (_workflow: AlgoliaWorkflowTemplate) => {
|
||||
// TODO: Implement workflow run
|
||||
}
|
||||
|
||||
const handleMakeCopy = (_workflow: AlgoliaWorkflowTemplate) => {
|
||||
// TODO: Implement make a copy
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
() => {
|
||||
selectedWorkflow.value = null
|
||||
}
|
||||
)
|
||||
</script>
|
||||
Reference in New Issue
Block a user