Done
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-07-04 23:09:00 +10:00
parent 0485b85a59
commit 439139a057
50 changed files with 2405 additions and 548 deletions
+5 -2
View File
@@ -1,4 +1,4 @@
import '../css/app.css';
import '../css/app.sass';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
@@ -6,11 +6,13 @@ import { createApp, h } from 'vue';
import type {DefineComponent} from 'vue';
import MainLayout from '@/layouts/MainLayout.vue';
import vuetify from '@/plugins/vuetify';
import { ZiggyVue } from 'ziggy-js'
import '@mdi/font/css/materialdesignicons.css';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
title: (title) => (title ? `${title} | ${appName}` : appName),
resolve: (name) => {
const page = resolvePageComponent(
@@ -28,6 +30,7 @@ createInertiaApp({
return createApp({ render: () => h(App, props) })
.use(plugin)
.use(vuetify)
.use(ZiggyVue)
.mount(el);
},
+175
View File
@@ -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>
+64
View File
@@ -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>
+116
View File
@@ -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>
+181
View File
@@ -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>
+17
View File
@@ -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>
+14 -5
View File
@@ -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
+36
View File
@@ -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>
+12
View File
@@ -0,0 +1,12 @@
import { usePage as useInertiaPage } from '@inertiajs/vue3';
import type { SharedData } from '@/types/types';
/**
* Typed drop-in replacement for Inertia's usePage().
* Usage: import { usePage } from '@/composables/usePage';
* const page = usePage();
* page.props.auth.user?.name // fully typed
*/
export function usePage() {
return useInertiaPage<SharedData>();
}
+62 -89
View File
@@ -1,35 +1,22 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Head, router } from '@inertiajs/vue3';
import { ref } from 'vue';
import { useDisplay } from 'vuetify';
import Masthead from '@/components/Masthead.vue';
import { usePage } from '@/composables/usePage';
import CategoryNavBar from '@/components/CategoryNavBar.vue';
import { route } from 'ziggy-js';
defineProps<{
title?: string;
}>();
const page = usePage().props;
const { mobile } = useDisplay();
const drawer = ref(false);
const navItems = [
{ label: 'Top Stories', href: '/' },
{ label: 'World', href: '/world' },
{ label: 'Technology', href: '/technology' },
{ label: 'Business', href: '/business' },
{ label: 'Science', href: '/science' },
];
const subItems = [
'Headlines',
'Australia',
'UK',
'USA',
'Politics',
'Climate',
'Health',
'Sport',
'Arts',
];
const logout = () => router.post(route('logout'));
</script>
<template>
@@ -44,13 +31,43 @@ const subItems = [
<!-- Desktop nav -->
<template v-if="!mobile">
<div class="d-flex ga-2 mr-4">
<VBtn variant="text" href="/login" class="nav-btn">
<div class="d-flex align-center ga-2 mr-4">
<VBtn
v-if="!page.auth.user"
variant="text"
:href="route('login')"
class="nav-btn"
>
Login
</VBtn>
<VBtn variant="outlined" href="/register" class="nav-btn">
<VBtn
v-if="!page.auth.user"
variant="outlined"
:href="route('register')"
class="nav-btn"
>
Register
</VBtn>
<div style="display: flex; align-items: center">
<span v-if="page.auth.user"
>Hello {{ page.auth.user.name }}!</span
>
</div>
<VBtn
v-if="page.auth.user"
icon="mdi-cog"
variant="text"
:href="route('settings.index')"
aria-label="Settings"
/>
<VBtn
v-if="page.auth.user"
variant="outlined"
class="nav-btn"
@click="logout"
>
Log Out
</VBtn>
</div>
</template>
@@ -58,24 +75,30 @@ const subItems = [
<VAppBarNavIcon v-else @click="drawer = !drawer" />
</VAppBar>
<VAppBar flat :height="37" color="surface" style="top: 56px">
<div class="red-rule" />
<div class="subbar">
<a
v-for="item in subItems"
:key="item"
href="#"
class="subbar-link"
>
{{ item }}
</a>
</div>
</VAppBar>
<CategoryNavBar />
<VNavigationDrawer v-model="drawer" temporary>
<VList>
<VListItem href="/login" title="Login" />
<VListItem href="/register" title="Register" />
<VListItem
v-if="!page.auth.user"
:href="route('login')"
title="Login"
/>
<VListItem
v-if="!page.auth.user"
:href="route('register')"
title="Register"
/>
<VListItem
v-if="page.auth.user"
:href="route('settings.index')"
title="Settings"
/>
<VListItem
v-if="page.auth.user"
title="Log Out"
@click="logout"
/>
</VList>
</VNavigationDrawer>
@@ -90,16 +113,7 @@ const subItems = [
<VFooter app color="#111111" class="footer">
<Masthead small light />
<VSpacer />
<nav class="d-flex ga-4">
<a
v-for="link in ['About', 'Donate']"
:key="link"
:href="`/${link.toLowerCase()}`"
class="footer-link"
>
{{ link }}
</a>
</nav>
<nav class="d-flex ga-4"></nav>
<v-spacer />
<span class="footer-copy"
>© {{ new Date().getFullYear() }} DredgeNews</span
@@ -109,7 +123,6 @@ const subItems = [
</template>
<style scoped lang="sass">
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;500&display=swap')
.nav-btn
font-family: 'Inter', sans-serif
@@ -139,46 +152,6 @@ const subItems = [
color: #111
border-bottom-color: #C0392B
.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
&:hover
color: #111
&.active
color: #111
border-bottom-color: #111
.content-well
max-width: 1080px
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import MainLayout from '@/layouts/MainLayout.vue';
import PageCard from '@/components/PageCard.vue';
import SectionHeading from '@/components/SectionHeading.vue';
defineOptions({ layout: MainLayout });
defineProps<{
status?: string;
}>();
const form = useForm({
email: '',
password: '',
remember: false,
});
const submit = () => {
form.post(route('login.store'), {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<Head title="Login" />
<VContainer class="d-flex justify-center py-8">
<PageCard max-width="400">
<template #header>
<SectionHeading
title="Sign in"
subtitle="Welcome back. Enter your details to continue reading."
/>
</template>
<VAlert
v-if="status"
type="success"
variant="tonal"
density="compact"
class="mb-4"
>
{{ status }}
</VAlert>
<VForm @submit.prevent="submit">
<VTextField
v-model="form.email"
label="Email address"
type="email"
autofocus
autocomplete="username"
:error-messages="form.errors.email"
class="mb-4"
/>
<VTextField
v-model="form.password"
label="Password"
type="password"
autocomplete="current-password"
:error-messages="form.errors.password"
class="mb-1"
/>
<VCheckbox
v-model="form.remember"
label="Keep me signed in"
density="compact"
hide-details
class="mb-4"
/>
<VBtn
type="submit"
block
size="large"
color="primary"
:loading="form.processing"
>
Sign in
</VBtn>
</VForm>
<template #footer>
New to DredgeNews?
<Link
:href="route('register')"
class="text-primary font-weight-medium"
>
Create an account
</Link>
</template>
</PageCard>
</VContainer>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import MainLayout from '@/layouts/MainLayout.vue';
import PageCard from '@/components/PageCard.vue';
import SectionHeading from '@/components/SectionHeading.vue';
defineOptions({ layout: MainLayout });
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('register.store'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<Head title="Register" />
<VContainer class="d-flex justify-center py-8">
<PageCard max-width="400">
<template #header>
<SectionHeading
eyebrow="Join DredgeNews"
title="Create your account"
subtitle="Unlimited stories, straight reporting, no noise."
/>
</template>
<VForm @submit.prevent="submit">
<VTextField
v-model="form.name"
label="Full name"
autofocus
autocomplete="name"
:error-messages="form.errors.name"
class="mb-4"
/>
<VTextField
v-model="form.email"
label="Email address"
type="email"
autocomplete="username"
:error-messages="form.errors.email"
class="mb-4"
/>
<VTextField
v-model="form.password"
label="Password"
type="password"
autocomplete="new-password"
:error-messages="form.errors.password"
class="mb-4"
/>
<VTextField
v-model="form.password_confirmation"
label="Confirm password"
type="password"
autocomplete="new-password"
:error-messages="form.errors.password_confirmation"
class="mb-4"
/>
<VBtn
type="submit"
block
size="large"
color="primary"
:loading="form.processing"
>
Create account
</VBtn>
</VForm>
<template #footer>
Already have an account?
<Link
:href="route('login')"
class="text-primary font-weight-medium"
>Sign in</Link
>
</template>
</PageCard>
</VContainer>
</template>
+14 -252
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import MainLayout from '@/layouts/MainLayout.vue';
import CategorySection from '@/components/CategorySection.vue';
defineOptions({
layout: MainLayout,
@@ -7,9 +8,11 @@ defineOptions({
interface Story {
source: string;
time: string;
time: string | null;
headline: string;
deck?: string;
deck?: string | null;
image?: string | null;
link: string;
}
interface Category {
@@ -18,152 +21,19 @@ interface Category {
items: Story[];
}
const categories: Category[] = [
{
label: 'Headlines',
hero: true,
items: [
{ source: 'Reuters', time: '42 minutes ago', headline: 'Leaders gather in Brussels as ceasefire talks enter critical third day', deck: 'Negotiators signalled cautious progress overnight, with both delegations agreeing to extend the informal moratorium on strikes.' },
{ source: 'Financial Times', time: '1 hour ago', headline: 'Markets edge higher on softer-than-expected inflation print', deck: 'Central banks signal caution as core inflation falls for the second consecutive month across G7 economies.' },
{ source: 'BBC News', time: '30 minutes ago', headline: 'Record heat across southern Europe for third week running' },
{ source: 'AP News', time: '55 minutes ago', headline: 'Congress reaches last-minute deal to avert government shutdown' },
{ source: 'The Guardian', time: '1 hour ago', headline: 'Tech giants face fresh scrutiny over data sharing practices' },
],
},
{
label: 'Australia',
hero: false,
items: [
{ source: 'ABC News', time: '8 minutes ago', headline: 'Treasurer signals pre-election budget revision in August' },
{ source: 'Sydney Morning Herald', time: '1 hour ago', headline: 'East coast housing approvals fall for third consecutive quarter' },
{ source: 'The Australian', time: '3 hours ago', headline: 'Queensland floods ease but thousands remain displaced' },
],
},
{
label: 'UK',
hero: false,
items: [
{ source: 'The Guardian', time: '20 minutes ago', headline: 'Prime Minister faces backbench revolt over public sector pay offer' },
{ source: 'BBC News', time: '1 hour ago', headline: 'London rents rise for twelfth consecutive month as supply tightens' },
{ source: 'The Times', time: '2 hours ago', headline: 'NHS waiting lists fall slightly but remain at historic highs' },
],
},
{
label: 'USA',
hero: false,
items: [
{ source: 'AP News', time: '15 minutes ago', headline: 'Congress reaches last-minute deal to avert government shutdown' },
{ source: 'Washington Post', time: '50 minutes ago', headline: 'Federal Reserve signals caution on further rate adjustments' },
{ source: 'New York Times', time: '2 hours ago', headline: 'Midwest storms leave hundreds of thousands without power' },
],
},
{
label: 'Politics',
hero: false,
items: [
{ source: 'Politico', time: '18 minutes ago', headline: 'Senate committee advances sweeping electoral reform bill' },
{ source: 'The Hill', time: '55 minutes ago', headline: 'Opposition leader calls for early election amid polling slump' },
{ source: 'AP News', time: '2 hours ago', headline: 'Foreign ministers meet in Geneva over disputed border region' },
],
},
{
label: 'Climate',
hero: false,
items: [
{ source: 'BBC News', time: '30 minutes ago', headline: 'Record heat across southern Europe for third week running' },
{ source: 'The Guardian', time: '1 hour ago', headline: 'Arctic sea ice extent hits lowest July measurement on record' },
{ source: 'Nature', time: '4 hours ago', headline: 'New modelling suggests 2°C target may be breached by 2031' },
],
},
{
label: 'Health',
hero: false,
items: [
{ source: 'Reuters', time: '25 minutes ago', headline: 'WHO recommends updated booster schedule ahead of winter' },
{ source: 'New Scientist', time: '2 hours ago', headline: "Trial drug shows promise in slowing early-onset Alzheimer's" },
{ source: 'The Lancet', time: '5 hours ago', headline: 'Childhood obesity rates plateau in high-income countries for first time' },
],
},
{
label: 'Sport',
hero: false,
items: [
{ source: 'The Guardian', time: '10 minutes ago', headline: 'Australian side advances after dramatic penalty shootout' },
{ source: 'ESPN', time: '1 hour ago', headline: 'Transfer window: three clubs circle record-breaking midfielder' },
{ source: 'Reuters', time: '3 hours ago', headline: 'Athletics world championships draw record television audience' },
],
},
{
label: 'Arts',
hero: false,
items: [
{ source: 'The New Yorker', time: '40 minutes ago', headline: 'Booker Prize longlist announced, debut novelists dominate' },
{ source: 'Pitchfork', time: '2 hours ago', headline: 'Acclaimed composer releases first album in seven years' },
{ source: 'Artforum', time: '6 hours ago', headline: 'Venice Biennale attendance surpasses pre-pandemic figures' },
],
},
];
defineProps<{
categories: Category[];
}>();
</script>
<template>
<div class="feed">
<section v-for="category in categories" :key="category.label" class="category">
<div class="category-header">
<span class="category-label">{{ category.label }}</span>
<a href="#" class="category-more">More</a>
</div>
<!-- Hero layout for flagged categories -->
<template v-if="category.hero">
<div class="hero-grid">
<a v-for="item in category.items.slice(0, 2)"
:key="item.headline"
href="#"
class="hero-story"
>
<div class="story-img story-img--hero" />
<div class="story-body">
<div class="story-source">{{ item.source }}</div>
<div class="story-headline story-headline--hero">{{ item.headline }}</div>
<div v-if="item.deck" class="story-deck">{{ item.deck }}</div>
<div class="story-time">{{ item.time }}</div>
</div>
</a>
</div>
<div class="story-grid">
<a v-for="item in category.items.slice(2)"
:key="item.headline"
href="#"
class="story"
>
<div class="story-img" />
<div class="story-body">
<div class="story-source">{{ item.source }}</div>
<div class="story-headline">{{ item.headline }}</div>
<div class="story-time">{{ item.time }}</div>
</div>
</a>
</div>
</template>
<!-- Standard 3-column grid -->
<div v-else class="story-grid">
<a v-for="item in category.items"
:key="item.headline"
href="#"
class="story"
>
<div class="story-img" />
<div class="story-body">
<div class="story-source">{{ item.source }}</div>
<div class="story-headline">{{ item.headline }}</div>
<div class="story-time">{{ item.time }}</div>
</div>
</a>
</div>
</section>
<CategorySection
v-for="(category, index) in categories"
:key="category.label"
:category="category"
:index="index"
/>
</div>
</template>
@@ -171,112 +41,4 @@ const categories: Category[] = [
.feed
display: flex
flex-direction: column
.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
// Hero grid (2 columns)
.hero-grid
display: grid
grid-template-columns: 1fr 1fr
gap: 1em
background: #E8E8E8
margin-bottom: 1px
.hero-story
background: #F7F7F5
text-decoration: none
display: flex
flex-direction: column
&:hover
background: #fff
// Standard story grid (3 columns)
.story-grid
display: grid
grid-template-columns: repeat(3, 1fr)
gap: 1px
background: #E8E8E8
.story
background: #F7F7F5
text-decoration: none
display: flex
flex-direction: column
&:hover
background: #fff
// Image placeholders
.story-img
width: 100%
height: 110px
background: #DDDDD8
flex-shrink: 0
&--hero
height: 200px
// Story body
.story-body
padding: 0.85rem
display: flex
flex-direction: column
gap: 5px
flex: 1
.story-source
font-size: 10px
font-weight: 600
letter-spacing: 1px
text-transform: uppercase
color: #C0392B
.story-headline
font-family: Georgia, serif
font-size: 13px
font-weight: 700
color: #111
line-height: 1.35
&--hero
font-size: 18px
line-height: 1.25
.story-deck
font-size: 12px
color: #555
line-height: 1.5
.story-time
font-size: 10px
color: #999
margin-top: auto
padding-top: 4px
</style>
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { computed, ref } from 'vue';
import MainLayout from '@/layouts/MainLayout.vue';
import SectionHeading from '@/components/SectionHeading.vue';
import CategoryDialog from '@/components/Settings/Categories/CategoryDialog.vue';
import type { Category, Feed } from '@/types/types';
defineOptions({ layout: MainLayout });
const props = defineProps<{
categories: Category[];
}>();
const dialogOpen = ref(false);
const editingCategory = ref<Category | null>(null);
const dialogDefaultParentId = ref<number | null>(null);
const parentOptions = computed(() =>
props.categories.map((c) => ({ title: c.title, value: c.id })),
);
const openCreate = (parentId: number | null = null) => {
editingCategory.value = null;
dialogDefaultParentId.value = parentId;
dialogOpen.value = true;
};
const openEdit = (category: Category) => {
editingCategory.value = category;
dialogOpen.value = true;
};
const destroy = (category: Category) => {
if (
!confirm(
`Delete "${category.title}"? This also deletes its subcategories and feeds.`,
)
)
return;
router.delete(route('categories.destroy', category.id), {
preserveScroll: true,
});
};
const feedLabel = (feed: Feed) => feed.type.toUpperCase();
</script>
<template>
<Head title="Feeds" />
<VContainer class="py-8" style="max-width: 720px">
<div class="d-flex align-center justify-space-between mb-6">
<SectionHeading
eyebrow="Settings"
title="Feed categories"
subtitle="Organize the RSS feeds you follow into categories and subcategories."
/>
<VBtn color="primary" @click="openCreate()">New category</VBtn>
</div>
<p v-if="!categories.length" class="text-body-2 text-medium-emphasis">
No categories yet. Create one to start adding feeds.
</p>
<VCard
v-for="category in categories"
:key="category.id"
variant="outlined"
class="mb-4"
>
<VCardText class="pa-4">
<div class="d-flex align-start justify-space-between">
<div>
<h3 class="text-h6 font-weight-bold mb-1">
{{ category.title }}
</h3>
<p
v-if="category.description"
class="text-body-2 text-medium-emphasis mb-2"
>
{{ category.description }}
</p>
<div
v-for="feed in category.feeds"
:key="feed.id"
class="d-flex align-center ga-2 mb-1"
>
<span class="text-caption text-medium-emphasis">{{
feed.url
}}</span>
<VChip size="x-small" variant="outlined">{{
feedLabel(feed)
}}</VChip>
<VChip
v-if="feed.paywall"
size="x-small"
color="warning"
variant="outlined"
>
Paywalled
</VChip>
</div>
</div>
<div class="d-flex ga-1">
<VBtn
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(category)"
/>
<VBtn
icon="mdi-delete"
size="small"
variant="text"
@click="destroy(category)"
/>
</div>
</div>
<VDivider v-if="category.children?.length" class="my-4" />
<div
v-for="child in category.children"
:key="child.id"
class="mb-3 ml-4"
>
<div class="d-flex align-start justify-space-between">
<div>
<h4 class="text-subtitle-1 font-weight-medium mb-1">
{{ child.title }}
</h4>
<p
v-if="child.description"
class="text-body-2 text-medium-emphasis mb-2"
>
{{ child.description }}
</p>
<div
v-for="feed in child.feeds"
:key="feed.id"
class="d-flex align-center ga-2 mb-1"
>
<span
class="text-caption text-medium-emphasis"
>{{ feed.url }}</span
>
<VChip size="x-small" variant="outlined">{{
feedLabel(feed)
}}</VChip>
<VChip
v-if="feed.paywall"
size="x-small"
color="warning"
variant="outlined"
>
Paywalled
</VChip>
</div>
</div>
<div class="d-flex ga-1">
<VBtn
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(child)"
/>
<VBtn
icon="mdi-delete"
size="small"
variant="text"
@click="destroy(child)"
/>
</div>
</div>
</div>
<VBtn
variant="text"
size="small"
color="primary"
class="ml-4"
@click="openCreate(category.id)"
>
Add subcategory
</VBtn>
</VCardText>
</VCard>
<CategoryDialog
v-model="dialogOpen"
:category="editingCategory"
:parent-options="parentOptions"
:default-parent-id="dialogDefaultParentId"
/>
</VContainer>
</template>
+8
View File
@@ -1,6 +1,7 @@
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { md3 } from 'vuetify/blueprints';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
export default createVuetify({
theme: {
@@ -16,6 +17,13 @@ export default createVuetify({
},
},
},
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
defaults: {
global: {
rounded: false,
+47
View File
@@ -0,0 +1,47 @@
import type { Config as ZiggyConfig } from 'ziggy-js';
export interface User {
id: number;
name: string;
email: string;
email_verified_at: string | null;
}
export interface Auth {
user: User | null;
}
export interface NavCategory {
id: number;
parent_id: number | null;
title: string;
children?: NavCategory[];
}
export interface SharedData {
name: string;
auth: Auth;
navigation_categories: NavCategory[];
ziggy: ZiggyConfig & { location: string };
[key: string]: unknown;
}
export type FeedType = 'rss' | 'atom';
export interface Feed {
id: number;
url: string;
type: FeedType;
paywall: boolean;
}
export interface Category {
id: number;
parent_id: number | null;
title: string;
description: string | null;
feeds: Feed[];
children?: Category[];
}
+10
View File
@@ -0,0 +1,10 @@
// resources/js/types/ziggy.d.ts
import { route as routeFn } from 'ziggy-js';
declare module 'vue' {
interface ComponentCustomProperties {
route: typeof routeFn;
}
}
export {};