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
+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>