Done
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
interface Story {
|
||||
source: string;
|
||||
time: string | null;
|
||||
headline: string;
|
||||
deck?: string | null;
|
||||
image?: string | null;
|
||||
link: string;
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
story: Story;
|
||||
variant?: 'hero' | 'banner' | 'feature' | 'standard' | 'brief';
|
||||
}>(),
|
||||
{
|
||||
variant: 'standard',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="story.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card"
|
||||
:class="[
|
||||
`card--${variant}`,
|
||||
{ 'card--no-image': variant !== 'brief' && !story.image },
|
||||
]"
|
||||
>
|
||||
<div v-if="variant !== 'brief' && story.image" class="card-img">
|
||||
<img :src="story.image" :alt="story.headline" loading="lazy" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-source">{{ story.source }}</div>
|
||||
<div class="card-headline">{{ story.headline }}</div>
|
||||
<div v-if="variant === 'hero' && story.deck" class="card-deck">
|
||||
{{ story.deck }}
|
||||
</div>
|
||||
<div v-if="story.time" class="card-time">{{ story.time }}</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.card
|
||||
display: flex
|
||||
flex-direction: column
|
||||
text-decoration: none
|
||||
background: #F7F7F5
|
||||
|
||||
&:hover
|
||||
background: #fff
|
||||
|
||||
.card-img
|
||||
width: 100%
|
||||
background: #DDDDD8
|
||||
overflow: hidden
|
||||
flex-shrink: 0
|
||||
|
||||
img
|
||||
width: 100%
|
||||
height: 100%
|
||||
object-fit: cover
|
||||
display: block
|
||||
|
||||
.card-body
|
||||
padding: 0.85rem
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 5px
|
||||
flex: 1
|
||||
|
||||
.card-source
|
||||
font-size: 10px
|
||||
font-weight: 600
|
||||
letter-spacing: 1px
|
||||
text-transform: uppercase
|
||||
color: #C0392B
|
||||
|
||||
.card-headline
|
||||
font-family: Georgia, serif
|
||||
color: #111
|
||||
line-height: 1.3
|
||||
|
||||
.card-deck
|
||||
font-size: 13px
|
||||
color: #555
|
||||
line-height: 1.55
|
||||
|
||||
.card-time
|
||||
font-size: 10px
|
||||
color: #999
|
||||
margin-top: auto
|
||||
padding-top: 4px
|
||||
|
||||
// Hero: the big lead story
|
||||
.card--hero
|
||||
.card-img
|
||||
height: 400px
|
||||
.card-headline
|
||||
font-size: 26px
|
||||
font-weight: 700
|
||||
|
||||
// Banner: full-width lead when the section has no true hero,
|
||||
// needs a taller image than feature since it's stretched much wider
|
||||
.card--banner
|
||||
.card-img
|
||||
height: 540px
|
||||
.card-headline
|
||||
font-size: 20px
|
||||
font-weight: 700
|
||||
|
||||
// Feature: supporting stories with a medium image
|
||||
.card--feature
|
||||
.card-img
|
||||
height: 260px
|
||||
.card-headline
|
||||
font-size: 17px
|
||||
font-weight: 700
|
||||
|
||||
// Standard: small image, compact headline
|
||||
.card--standard
|
||||
.card-img
|
||||
height: 150px
|
||||
.card-headline
|
||||
font-size: 13px
|
||||
font-weight: 700
|
||||
|
||||
// No image available: give the text more breathing room instead of
|
||||
// leaving an empty gray box where a photo would have been
|
||||
.card--no-image
|
||||
.card-body
|
||||
padding: 1rem 0.85rem
|
||||
justify-content: center
|
||||
|
||||
// Brief: text only, single row, no image at all
|
||||
.card--brief
|
||||
flex-direction: row
|
||||
align-items: baseline
|
||||
justify-content: space-between
|
||||
gap: 12px
|
||||
background: transparent
|
||||
padding: 0.55rem 0
|
||||
border-bottom: 1px solid #E8E8E8
|
||||
|
||||
&:hover
|
||||
background: transparent
|
||||
|
||||
.card-headline
|
||||
text-decoration: underline
|
||||
|
||||
.card-body
|
||||
padding: 0
|
||||
flex-direction: row
|
||||
align-items: baseline
|
||||
gap: 8px
|
||||
flex: 1
|
||||
|
||||
.card-source
|
||||
display: none
|
||||
|
||||
.card-headline
|
||||
font-size: 13px
|
||||
font-weight: 400
|
||||
flex: 1
|
||||
|
||||
.card-time
|
||||
font-size: 10px
|
||||
color: #999
|
||||
padding-top: 0
|
||||
white-space: nowrap
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<div v-if="eyebrow" class="auth-eyebrow">{{ eyebrow }}</div>
|
||||
<h1 class="auth-title">{{ title }}</h1>
|
||||
<p class="auth-sub">{{ subtitle }}</p>
|
||||
|
||||
<slot name="alert" />
|
||||
|
||||
<slot />
|
||||
|
||||
<div class="auth-rule" />
|
||||
|
||||
<p class="auth-footer">
|
||||
<slot name="footer" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.auth-wrap
|
||||
display: flex
|
||||
justify-content: center
|
||||
padding: 2.5rem 1rem
|
||||
|
||||
.auth-card
|
||||
width: 100%
|
||||
max-width: 400px
|
||||
background: #fff
|
||||
border: 1px solid #E8E8E8
|
||||
padding: 2.5rem 2.25rem
|
||||
|
||||
.auth-eyebrow
|
||||
font-family: 'Inter', sans-serif
|
||||
font-size: 11px
|
||||
font-weight: 500
|
||||
letter-spacing: 0.08em
|
||||
text-transform: uppercase
|
||||
color: #C0392B
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
.auth-title
|
||||
font-family: 'Playfair Display', serif
|
||||
font-weight: 700
|
||||
font-size: 30px
|
||||
color: #111
|
||||
margin: 0 0 0.4rem
|
||||
|
||||
.auth-sub
|
||||
font-family: 'Inter', sans-serif
|
||||
font-size: 13px
|
||||
color: #666
|
||||
margin: 0 0 1.75rem
|
||||
line-height: 1.5
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
autocomplete?: string;
|
||||
modelValue: string;
|
||||
error?: string;
|
||||
}>();
|
||||
defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="field-label" :for="id">{{ label }}</label>
|
||||
<VTextField
|
||||
:id="id"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:type="type ?? 'text'"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:autocomplete="autocomplete"
|
||||
:error-messages="error"
|
||||
class="field"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { usePage } from '@/composables/usePage';
|
||||
|
||||
const page = usePage();
|
||||
const navCategories = page.props.navigation_categories;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VAppBar
|
||||
v-if="page.props.auth.user"
|
||||
flat
|
||||
:height="37"
|
||||
color="surface"
|
||||
style="top: 56px"
|
||||
>
|
||||
<div class="red-rule" />
|
||||
<div class="subbar">
|
||||
<template v-for="category in navCategories" :key="category.id">
|
||||
<a
|
||||
v-if="!category.children?.length"
|
||||
href="#"
|
||||
class="subbar-link"
|
||||
>
|
||||
{{ category.title }}
|
||||
</a>
|
||||
|
||||
<VMenu v-else open-on-hover location="bottom start" :offset="2">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<a
|
||||
href="#"
|
||||
class="subbar-link"
|
||||
v-bind="menuProps"
|
||||
@click.prevent
|
||||
>
|
||||
{{ category.title }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<VList class="submenu" density="compact" elevation="4">
|
||||
<VListItem
|
||||
v-for="child in category.children"
|
||||
:key="child.id"
|
||||
:title="child.title"
|
||||
href="#"
|
||||
class="submenu-item"
|
||||
/>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</template>
|
||||
</div>
|
||||
</VAppBar>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.red-rule
|
||||
height: 3px
|
||||
background: #C0392B
|
||||
width: 100%
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
z-index: 1
|
||||
|
||||
.subbar
|
||||
width: 100%
|
||||
background: #fff
|
||||
border-bottom: 1px solid #E8E8E8
|
||||
display: flex
|
||||
overflow-x: auto
|
||||
padding: 0 1.5rem
|
||||
position: absolute
|
||||
top: 3px
|
||||
left: 0
|
||||
|
||||
&::-webkit-scrollbar
|
||||
display: none
|
||||
|
||||
.subbar-link
|
||||
font-family: 'Inter', sans-serif
|
||||
font-size: 12px
|
||||
color: #666
|
||||
text-decoration: none
|
||||
padding: 8px 12px
|
||||
white-space: nowrap
|
||||
border-bottom: 2px solid transparent
|
||||
flex-shrink: 0
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
color: #111
|
||||
|
||||
&.active
|
||||
color: #111
|
||||
border-bottom-color: #111
|
||||
|
||||
.submenu
|
||||
min-width: 160px
|
||||
max-width: 240px
|
||||
border: 1px solid #E8E8E8
|
||||
padding: 4px 0
|
||||
background: #fff
|
||||
|
||||
.submenu-item
|
||||
font-family: 'Inter', sans-serif
|
||||
font-size: 13px
|
||||
color: #444
|
||||
min-height: 32px
|
||||
padding: 0 14px
|
||||
|
||||
:deep(.v-list-item-title)
|
||||
font-size: 13px
|
||||
|
||||
&:hover
|
||||
background: #F7F7F5
|
||||
color: #111
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import ArticleCard from './ArticleCard.vue';
|
||||
|
||||
interface Story {
|
||||
source: string;
|
||||
time: string | null;
|
||||
headline: string;
|
||||
deck?: string | null;
|
||||
image?: string | null;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
label: string;
|
||||
hero: boolean;
|
||||
items: Story[];
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
category: Category;
|
||||
index?: number;
|
||||
}>(),
|
||||
{
|
||||
index: 0,
|
||||
},
|
||||
);
|
||||
|
||||
type Variant = 'hero' | 'banner' | 'feature' | 'standard' | 'brief';
|
||||
|
||||
interface Slot {
|
||||
variant: Variant;
|
||||
span: number;
|
||||
}
|
||||
|
||||
// A hero category always gets the same big-lead treatment. Non-hero
|
||||
// categories rotate through a few distinct shapes by position, so
|
||||
// adjacent sections don't all read as the same grid: one leans on two
|
||||
// medium photos, one leans on a single wide banner, one is mostly
|
||||
// small thumbnails with more falling through to the text-only list.
|
||||
const nonHeroLayouts: Slot[][] = [
|
||||
// gallery: two medium features side by side, three small below
|
||||
[
|
||||
{ variant: 'feature', span: 3 },
|
||||
{ variant: 'feature', span: 3 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
],
|
||||
// lead: one wide banner feature, three small below
|
||||
[
|
||||
{ variant: 'banner', span: 6 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
],
|
||||
// digest: small thumbnails only, text-heavy section
|
||||
[
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
],
|
||||
];
|
||||
|
||||
const heroLayout: Slot[] = [
|
||||
{ variant: 'hero', span: 6 },
|
||||
{ variant: 'feature', span: 3 },
|
||||
{ variant: 'feature', span: 3 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
{ variant: 'standard', span: 2 },
|
||||
];
|
||||
|
||||
const slots = props.category.hero
|
||||
? heroLayout
|
||||
: nonHeroLayouts[props.index % nonHeroLayouts.length];
|
||||
|
||||
const gridItems = props.category.items
|
||||
.slice(0, slots.length)
|
||||
.map((story, i) => ({
|
||||
story,
|
||||
variant: slots[i].variant,
|
||||
span: slots[i].span,
|
||||
}));
|
||||
|
||||
const briefItems = props.category.items.slice(slots.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="category">
|
||||
<div class="category-header">
|
||||
<span class="category-label">{{ category.label }}</span>
|
||||
<a href="#" class="category-more">More</a>
|
||||
</div>
|
||||
|
||||
<div v-if="category.items.length === 0" class="category-empty">
|
||||
No stories yet. Feeds may still be fetching.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="section-grid">
|
||||
<ArticleCard
|
||||
v-for="entry in gridItems"
|
||||
:key="entry.story.link"
|
||||
:story="entry.story"
|
||||
:variant="entry.variant"
|
||||
:style="{ gridColumn: `span ${entry.span}` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="briefItems.length" class="brief-section">
|
||||
<div class="brief-label">In brief</div>
|
||||
<div class="brief-grid">
|
||||
<ArticleCard
|
||||
v-for="item in briefItems"
|
||||
:key="item.link"
|
||||
:story="item"
|
||||
variant="brief"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.category
|
||||
border-bottom: 1px solid #E8E8E8
|
||||
padding: 1.5rem 0
|
||||
|
||||
&:last-child
|
||||
border-bottom: none
|
||||
|
||||
.category-header
|
||||
display: flex
|
||||
align-items: baseline
|
||||
justify-content: space-between
|
||||
margin-bottom: 1rem
|
||||
|
||||
.category-label
|
||||
font-size: 10px
|
||||
font-weight: 600
|
||||
letter-spacing: 2px
|
||||
text-transform: uppercase
|
||||
color: #111
|
||||
|
||||
.category-more
|
||||
font-size: 12px
|
||||
color: #C0392B
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
text-decoration: underline
|
||||
|
||||
.category-empty
|
||||
font-size: 12px
|
||||
color: #999
|
||||
padding: 1rem 0
|
||||
|
||||
.section-grid
|
||||
display: grid
|
||||
grid-template-columns: repeat(6, 1fr)
|
||||
gap: 1px
|
||||
background: #E8E8E8
|
||||
|
||||
.brief-section
|
||||
margin-top: 4px
|
||||
|
||||
.brief-label
|
||||
font-size: 10px
|
||||
font-weight: 600
|
||||
letter-spacing: 1px
|
||||
text-transform: uppercase
|
||||
color: #999
|
||||
padding: 0.9rem 0 0.3rem
|
||||
|
||||
.brief-grid
|
||||
display: grid
|
||||
grid-template-columns: repeat(2, 1fr)
|
||||
column-gap: 2rem
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
text: string;
|
||||
color?: string;
|
||||
}>(),
|
||||
{
|
||||
color: 'primary',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-overline mb-1" :class="`text-${color}`">
|
||||
{{ text }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
defineProps<{
|
||||
light?: boolean;
|
||||
small?: boolean;
|
||||
@@ -6,14 +8,21 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['masthead', small && 'masthead-sm', light && 'masthead-light', !light && 'masthead-dark']">
|
||||
Dredge<span class="masthead-accent">News</span>
|
||||
</span>
|
||||
<Link style="text-decoration: none;" :href="route('home')">
|
||||
<span
|
||||
:class="[
|
||||
'masthead',
|
||||
small && 'masthead-sm',
|
||||
light && 'masthead-light',
|
||||
!light && 'masthead-dark',
|
||||
]"
|
||||
>
|
||||
Dredge<span class="masthead-accent">News</span>
|
||||
</span>
|
||||
</Link>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&display=swap')
|
||||
|
||||
.masthead
|
||||
font-family: 'Playfair Display', serif
|
||||
font-weight: 700
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
maxWidth?: string | number;
|
||||
centered?: boolean;
|
||||
}>(),
|
||||
{
|
||||
maxWidth: 480,
|
||||
centered: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="pa-6"
|
||||
:class="{ 'mx-auto': centered }"
|
||||
:style="{
|
||||
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
|
||||
}"
|
||||
>
|
||||
<div v-if="$slots.header" class="mb-6">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<template v-if="$slots.footer">
|
||||
<VDivider class="my-6" />
|
||||
<div class="text-body-2 text-medium-emphasis text-center">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import Eyebrow from '@/components/Eyebrow.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
eyebrowColor?: string;
|
||||
titleClass?: string;
|
||||
}>(),
|
||||
{
|
||||
titleClass: 'text-h4',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Eyebrow v-if="eyebrow" :text="eyebrow" :color="eyebrowColor" />
|
||||
<h1 class="font-weight-bold mb-1 font-serif" :class="titleClass">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
import { watch } from 'vue';
|
||||
import type { Category, Feed, FeedType } from '@/types/types';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
category?: Category | null;
|
||||
parentOptions: { title: string; value: number }[];
|
||||
defaultParentId?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const feedTypeOptions: { title: string; value: FeedType }[] = [
|
||||
{ title: 'RSS', value: 'rss' },
|
||||
];
|
||||
|
||||
type FeedInput = { url: string; type: FeedType; paywall: boolean };
|
||||
|
||||
const emptyFeed = (): FeedInput => ({ url: '', type: 'rss', paywall: false });
|
||||
|
||||
const form = useForm({
|
||||
title: '',
|
||||
description: '',
|
||||
parent_id: null as number | null,
|
||||
feeds: [emptyFeed()] as FeedInput[],
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
|
||||
if (props.category) {
|
||||
form.title = props.category.title;
|
||||
form.description = props.category.description ?? '';
|
||||
form.parent_id = props.category.parent_id;
|
||||
form.feeds = props.category.feeds.length
|
||||
? props.category.feeds.map((f: Feed) => ({
|
||||
url: f.url,
|
||||
type: f.type,
|
||||
paywall: f.paywall,
|
||||
}))
|
||||
: [emptyFeed()];
|
||||
} else {
|
||||
form.reset();
|
||||
form.parent_id = props.defaultParentId ?? null;
|
||||
form.feeds = [emptyFeed()];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const addFeed = () => form.feeds.push(emptyFeed());
|
||||
const removeFeed = (index: number) => form.feeds.splice(index, 1);
|
||||
|
||||
const close = () => emit('update:modelValue', false);
|
||||
|
||||
const submit = () => {
|
||||
const payload = {
|
||||
...form.data(),
|
||||
feeds: form.feeds.filter((feed) => feed.url.trim() !== ''),
|
||||
};
|
||||
|
||||
const options = {
|
||||
onSuccess: () => close(),
|
||||
preserveScroll: true,
|
||||
};
|
||||
|
||||
if (props.category) {
|
||||
form.transform(() => payload).put(
|
||||
route('categories.update', props.category.id),
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
form.transform(() => payload).post(route('categories.store'), options);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<VCard class="pa-6">
|
||||
<h2 class="text-h5 font-weight-bold mb-4">
|
||||
{{ category ? 'Edit category' : 'New category' }}
|
||||
</h2>
|
||||
|
||||
<VForm @submit.prevent="submit">
|
||||
<VTextField
|
||||
v-model="form.title"
|
||||
label="Title"
|
||||
autofocus
|
||||
:error-messages="form.errors.title"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<VTextarea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
rows="2"
|
||||
:error-messages="form.errors.description"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<VSelect
|
||||
v-model="form.parent_id"
|
||||
:items="parentOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="Parent category (optional)"
|
||||
clearable
|
||||
:error-messages="form.errors.parent_id"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<p class="text-caption text-medium-emphasis mb-2">Feeds</p>
|
||||
|
||||
<div
|
||||
v-for="(feed, index) in form.feeds"
|
||||
:key="index"
|
||||
class="pa-3 mb-3 rounded border"
|
||||
>
|
||||
<VTextField
|
||||
v-model="feed.url"
|
||||
label="URL"
|
||||
placeholder="https://example.com/feed.xml"
|
||||
density="compact"
|
||||
:error-messages="form.errors[`feeds.${index}.url`]"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center ga-3">
|
||||
<VSelect
|
||||
v-model="feed.type"
|
||||
:items="feedTypeOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="Type"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 140px"
|
||||
/>
|
||||
|
||||
<VCheckbox
|
||||
v-model="feed.paywall"
|
||||
label="Paywalled"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<VBtn
|
||||
icon="mdi-close"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="form.feeds.length === 1"
|
||||
@click="removeFeed(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="mb-6"
|
||||
@click="addFeed"
|
||||
>
|
||||
Add another feed
|
||||
</VBtn>
|
||||
|
||||
<div class="d-flex ga-2 justify-end">
|
||||
<VBtn variant="text" @click="close">Cancel</VBtn>
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="form.processing"
|
||||
>
|
||||
{{ category ? 'Save changes' : 'Create category' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user