feat: add zine-direction shared components
This commit is contained in:
parent
dbb3fbbc1b
commit
8b3daadadd
10 changed files with 594 additions and 176 deletions
10
app/components/CircleBadge.vue
Normal file
10
app/components/CircleBadge.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<template>
|
||||||
|
<span class="badge" :class="circle">{{ label || circle }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
circle: { type: String, required: true },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
96
app/components/CirclePicker.vue
Normal file
96
app/components/CirclePicker.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<div class="circle-picker">
|
||||||
|
<div
|
||||||
|
v-for="circle in circles"
|
||||||
|
:key="circle.value"
|
||||||
|
class="circle-option"
|
||||||
|
:class="{ current: modelValue === circle.value }"
|
||||||
|
@click="$emit('update:modelValue', circle.value)"
|
||||||
|
>
|
||||||
|
<span class="circle-name">{{ circle.label }}</span>
|
||||||
|
<span class="circle-desc">{{ circle.description }}</span>
|
||||||
|
<span
|
||||||
|
v-if="modelValue === circle.value"
|
||||||
|
class="circle-tag"
|
||||||
|
:style="{ color: `var(--c-${circle.value})`, borderColor: `var(--c-${circle.value})` }"
|
||||||
|
>Current</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
circles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
|
||||||
|
{ value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' },
|
||||||
|
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative business' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.circle-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-option {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 14px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-option:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-option.current {
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-option.current .circle-name {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1.5;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-tag {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.circle-picker {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
app/components/DashedBox.vue
Normal file
11
app/components/DashedBox.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<div class="dashed-box" :class="{ 'no-hover': !hoverable }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
hoverable: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
98
app/components/EventsMiniSidebar.vue
Normal file
98
app/components/EventsMiniSidebar.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<template>
|
||||||
|
<aside class="events-mini">
|
||||||
|
<div class="em-label">Upcoming</div>
|
||||||
|
<div v-for="event in events" :key="event._id" class="em-item">
|
||||||
|
<span class="em-date">{{ formatDate(event.date) }}</span>
|
||||||
|
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
|
||||||
|
<span
|
||||||
|
v-if="event.circle"
|
||||||
|
class="em-circle"
|
||||||
|
:style="{ color: `var(--c-${event.circle})` }"
|
||||||
|
>{{ event.circle }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!events?.length" class="em-empty">No upcoming events</div>
|
||||||
|
<NuxtLink to="/events" class="em-link">All events →</NuxtLink>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
events: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.events-mini {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-left: 1px dashed var(--border);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-title:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-circle {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.events-mini {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
app/components/FilterBar.vue
Normal file
57
app/components/FilterBar.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<template>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<button
|
||||||
|
v-for="filter in filters"
|
||||||
|
:key="filter.value"
|
||||||
|
class="filter-btn"
|
||||||
|
:class="{ active: modelValue === filter.value }"
|
||||||
|
@click="$emit('update:modelValue', filter.value)"
|
||||||
|
>{{ filter.label }}</button>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
filters: { type: Array, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar {
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: var(--candle-faint);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
border-color: var(--candle-dim);
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--candle);
|
||||||
|
background: rgba(154, 116, 32, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,135 +1,33 @@
|
||||||
<template>
|
<template>
|
||||||
<header
|
<div class="page-header">
|
||||||
class="relative py-16 md:py-24 bg-cover bg-center"
|
<h1>{{ title }}</h1>
|
||||||
style="background-image: url('/background-dither.webp')"
|
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-black/40"></div>
|
|
||||||
<UContainer class="relative z-10">
|
|
||||||
<div class="text-center max-w-4xl mx-auto">
|
|
||||||
<h1
|
|
||||||
class="font-bold mb-6 md:mb-8 text-white"
|
|
||||||
:class="titleSizeClass"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="subtitle"
|
|
||||||
class="text-lg md:text-xl leading-relaxed mb-8 text-white/90"
|
|
||||||
>
|
|
||||||
{{ subtitle }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Interactive Content Area (for hero sections with carousels) -->
|
|
||||||
<div
|
|
||||||
v-if="showInteractiveArea"
|
|
||||||
class="rounded-2xl p-6 md:p-8 mb-12 backdrop-blur-sm bg-guild-800/60 border border-guild-700 candlelight-glow halftone-texture"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-3 md:gap-4">
|
|
||||||
<button
|
|
||||||
class="p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0 bg-candlelight-600/80 text-guild-100 hover:bg-candlelight-500 candlelight-glow"
|
|
||||||
@click="$emit('prev')"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-chevron-left" class="size-5 md:size-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="text-center flex-1 min-w-0">
|
|
||||||
<slot name="interactive-content">
|
|
||||||
<p class="text-base md:text-lg text-guild-200">
|
|
||||||
{{ interactiveContent }}
|
|
||||||
</p>
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0 bg-candlelight-600/80 text-guild-100 hover:bg-candlelight-500 candlelight-glow"
|
|
||||||
@click="$emit('next')"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-chevron-right" class="size-5 md:size-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Illustration slot for designer-provided assets -->
|
|
||||||
<div v-if="$slots.illustration" class="mb-8">
|
|
||||||
<slot name="illustration" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Call to Action Button -->
|
|
||||||
<div v-if="showCta" class="flex justify-center">
|
|
||||||
<UButton
|
|
||||||
:to="ctaLink"
|
|
||||||
:size="ctaSize"
|
|
||||||
:color="ctaColor"
|
|
||||||
class="font-semibold"
|
|
||||||
>
|
|
||||||
{{ ctaText }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Content Slot -->
|
|
||||||
<div v-if="$slots.default">
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</UContainer>
|
|
||||||
</header>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
title: {
|
title: { type: String, required: true },
|
||||||
type: String,
|
subtitle: { type: String, default: '' },
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: 'large',
|
|
||||||
validator: (value) => ['small', 'medium', 'large', 'hero'].includes(value),
|
|
||||||
},
|
|
||||||
showInteractiveArea: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
interactiveContent: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
showCta: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
ctaText: {
|
|
||||||
type: String,
|
|
||||||
default: 'Get Started',
|
|
||||||
},
|
|
||||||
ctaLink: {
|
|
||||||
type: String,
|
|
||||||
default: '/join',
|
|
||||||
},
|
|
||||||
ctaSize: {
|
|
||||||
type: String,
|
|
||||||
default: 'lg',
|
|
||||||
},
|
|
||||||
ctaColor: {
|
|
||||||
type: String,
|
|
||||||
default: 'primary',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['prev', 'next'])
|
|
||||||
|
|
||||||
const titleSizeClass = computed(() => {
|
|
||||||
const sizes = {
|
|
||||||
small: 'text-display-sm',
|
|
||||||
medium: 'text-display',
|
|
||||||
large: 'text-display-lg',
|
|
||||||
hero: 'text-display-xl',
|
|
||||||
}
|
|
||||||
return sizes[props.size] || sizes.large
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
padding: 24px 28px 16px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.page-header h1 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
35
app/components/ParchmentInset.vue
Normal file
35
app/components/ParchmentInset.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<div class="parchment-inset">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.parchment-inset {
|
||||||
|
background: var(--parch);
|
||||||
|
color: var(--parch-text);
|
||||||
|
padding: 32px;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parchment-inset :deep(h2) {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--parch-text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parchment-inset :deep(p) {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b8ae98;
|
||||||
|
line-height: 1.75;
|
||||||
|
max-width: 560px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parchment-inset :deep(a) {
|
||||||
|
color: var(--candle-faint);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,56 +1,67 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="priv">
|
||||||
<span class="text-guild-400 text-xs font-medium"
|
|
||||||
>{{ label }}:</span
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
<span
|
||||||
class="text-xs transition-colors"
|
v-for="opt in options"
|
||||||
:class="
|
:key="opt.value"
|
||||||
isPrivate
|
:class="{ on: modelValue === opt.value }"
|
||||||
? 'text-guild-500'
|
@click="$emit('update:modelValue', opt.value)"
|
||||||
: 'text-candlelight-500 font-semibold'
|
>{{ opt.label }}</span>
|
||||||
"
|
|
||||||
>
|
|
||||||
Members
|
|
||||||
</span>
|
|
||||||
<USwitch
|
|
||||||
:model-value="isPrivate"
|
|
||||||
@update:model-value="togglePrivacy"
|
|
||||||
color="primary"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="text-xs transition-colors"
|
|
||||||
:class="
|
|
||||||
isPrivate
|
|
||||||
? 'text-candlelight-500 font-semibold'
|
|
||||||
: 'text-guild-500'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Private
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
modelValue: {
|
modelValue: { type: String, default: 'public' },
|
||||||
type: String,
|
|
||||||
default: 'members',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: 'Privacy',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const isPrivate = computed(() => props.modelValue === 'private')
|
const options = [
|
||||||
|
{ label: 'Public', value: 'public' },
|
||||||
const togglePrivacy = (value) => {
|
{ label: 'Members', value: 'members' },
|
||||||
emit('update:modelValue', value ? 'private' : 'members')
|
{ label: 'Private', value: 'private' },
|
||||||
}
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.priv {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span {
|
||||||
|
padding: 2px 7px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span + span {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span.on {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priv span.on + span {
|
||||||
|
border-left-color: var(--candle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
104
app/components/TagInput.vue
Normal file
104
app/components/TagInput.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<template>
|
||||||
|
<div class="tags" @click="focusInput">
|
||||||
|
<span v-for="(tag, i) in modelValue" :key="tag" class="tag">
|
||||||
|
{{ tag }}
|
||||||
|
<span class="rm" @click.stop="removeTag(i)">×</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
v-model="newTag"
|
||||||
|
@keydown.enter.prevent="addTag"
|
||||||
|
@keydown.backspace="handleBackspace"
|
||||||
|
:placeholder="modelValue?.length ? '' : placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
placeholder: { type: String, default: 'Add tag...' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const input = ref(null)
|
||||||
|
const newTag = ref('')
|
||||||
|
|
||||||
|
const focusInput = () => {
|
||||||
|
input.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
const tag = newTag.value.trim()
|
||||||
|
if (tag && !props.modelValue.includes(tag)) {
|
||||||
|
emit('update:modelValue', [...props.modelValue, tag])
|
||||||
|
}
|
||||||
|
newTag.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (index) => {
|
||||||
|
const tags = [...props.modelValue]
|
||||||
|
tags.splice(index, 1)
|
||||||
|
emit('update:modelValue', tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackspace = () => {
|
||||||
|
if (!newTag.value && props.modelValue.length) {
|
||||||
|
removeTag(props.modelValue.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tags {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 3px 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
background: var(--bg);
|
||||||
|
min-height: 30px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags:focus-within {
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px dashed var(--border-d);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm {
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm:hover {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags input {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
app/components/TierPicker.vue
Normal file
98
app/components/TierPicker.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<template>
|
||||||
|
<div class="tier-picker">
|
||||||
|
<div
|
||||||
|
v-for="tier in tiers"
|
||||||
|
:key="tier.amount"
|
||||||
|
class="tier-option"
|
||||||
|
:class="{ current: modelValue === tier.amount }"
|
||||||
|
@click="$emit('update:modelValue', tier.amount)"
|
||||||
|
>
|
||||||
|
<span class="tier-amount">{{ tier.display }}</span>
|
||||||
|
<span class="tier-label">{{ tier.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: Number, default: 0 },
|
||||||
|
tiers: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ amount: 0, display: '$0', label: 'Free' },
|
||||||
|
{ amount: 5, display: '$5', label: '/month' },
|
||||||
|
{ amount: 15, display: '$15', label: '/month' },
|
||||||
|
{ amount: 30, display: '$30', label: '/month' },
|
||||||
|
{ amount: 50, display: '$50', label: '/month' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tier-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option + .tier-option {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option.current {
|
||||||
|
border-color: var(--candle);
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-amount {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option.current .tier-amount {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-option.current .tier-label {
|
||||||
|
color: var(--candle-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tier-picker {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tier-option {
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue