mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - implement Weazel News app
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Weazel News">
|
||||
<defs>
|
||||
<linearGradient id="weazel-news-background" x1="18" y1="10" x2="112" y2="122" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#171717"/>
|
||||
<stop offset="1" stop-color="#050505"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="weazel-news-accent" x1="26" y1="24" x2="103" y2="105" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ff4a50"/>
|
||||
<stop offset="1" stop-color="#c9101c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="url(#weazel-news-background)"/>
|
||||
<path d="M28 31h72a8 8 0 0 1 8 8v58a8 8 0 0 1-8 8H28a8 8 0 0 1-8-8V39a8 8 0 0 1 8-8Z" fill="#f4f4f1"/>
|
||||
<path d="M20 46h88v-7a8 8 0 0 0-8-8H28a8 8 0 0 0-8 8v7Z" fill="url(#weazel-news-accent)"/>
|
||||
<path d="M31 58h34v26H31z" fill="#181818"/>
|
||||
<path d="M72 59h25M72 68h25M72 77h20M31 92h66" fill="none" stroke="#747474" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="m38 63 6 16 5-12 5 12 6-16" fill="none" stroke="#f4f4f1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="96" cy="38" r="3" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Newspaper } from 'lucide-vue-next'
|
||||
|
||||
import { isPhoneAppId, PHONE_APPS } from './apps'
|
||||
describe('app registry', () => {
|
||||
it('has unique ids and routes with the reference dock order', () => {
|
||||
@@ -58,6 +60,16 @@ describe('app registry', () => {
|
||||
labelKey: 'Apps.companies.name',
|
||||
route: '/apps/companies',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'weazel-news')).toMatchObject({
|
||||
category: 'social',
|
||||
dockOrder: null,
|
||||
gridOrder: 29,
|
||||
labelKey: 'Apps.weazelNews.name',
|
||||
route: '/apps/weazel-news',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'weazel-news')?.icon).toBe(
|
||||
Newspaper,
|
||||
)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'music')).toMatchObject({
|
||||
category: 'utilities',
|
||||
gridOrder: 26,
|
||||
@@ -135,6 +147,7 @@ describe('app registry', () => {
|
||||
expect(isPhoneAppId('skyride')).toBe(true)
|
||||
expect(isPhoneAppId('music')).toBe(true)
|
||||
expect(isPhoneAppId('companies')).toBe(true)
|
||||
expect(isPhoneAppId('weazel-news')).toBe(true)
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
|
||||
).toEqual([
|
||||
@@ -151,6 +164,7 @@ describe('app registry', () => {
|
||||
(app) => app.id,
|
||||
),
|
||||
).toEqual([
|
||||
'weazel-news',
|
||||
'picstagram',
|
||||
'feather',
|
||||
'fliptok',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ReceiptText,
|
||||
UsersRound,
|
||||
Building2,
|
||||
Newspaper,
|
||||
} from 'lucide-vue-next'
|
||||
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
|
||||
|
||||
@@ -72,6 +73,7 @@ import musicIcon from '@/assets/img/app-icons/music.svg'
|
||||
import featherIcon from '@/assets/img/app-icons/feather.svg'
|
||||
import crewLinkIcon from '@/assets/img/app-icons/crewlink.svg'
|
||||
import companiesIcon from '@/assets/img/app-icons/companies.svg'
|
||||
import weazelNewsIcon from '@/assets/img/app-icons/weazel-news.svg'
|
||||
import type {
|
||||
BuiltinPhoneAppDefinition,
|
||||
BuiltinPhoneAppId,
|
||||
@@ -83,6 +85,20 @@ import type {
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/weazel-news-app.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 29,
|
||||
icon: markRaw(Newspaper),
|
||||
iconClass: 'app-icon--weazel-news',
|
||||
iconImage: weazelNewsIcon,
|
||||
id: 'weazel-news',
|
||||
labelKey: 'Apps.weazelNews.name',
|
||||
route: '/apps/weazel-news',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
|
||||
@@ -414,6 +414,136 @@ const defaultLocales: LocaleTree = {
|
||||
request_failed: 'EasyShare is temporarily unavailable.',
|
||||
},
|
||||
},
|
||||
weazelNews: {
|
||||
name: 'Weazel News',
|
||||
brand: 'WEAZEL NEWS',
|
||||
navigation: 'Weazel News navigation',
|
||||
tabs: {
|
||||
home: 'Home',
|
||||
categories: 'Categories',
|
||||
search: 'Search',
|
||||
editorial: 'Editorial',
|
||||
},
|
||||
back: 'Back',
|
||||
retry: 'Try Again',
|
||||
loadMore: 'Load More',
|
||||
home: {
|
||||
eyebrow: 'Los Santos, live',
|
||||
latest: 'Latest News',
|
||||
},
|
||||
categories: {
|
||||
title: 'Categories',
|
||||
subtitle: 'Browse every story from the Weazel News desk.',
|
||||
all: 'All Stories',
|
||||
official: 'Official Notices',
|
||||
events: 'Events & Leisure',
|
||||
jobs: 'Jobs',
|
||||
news: 'News & Events',
|
||||
business: 'Business',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search articles...',
|
||||
title: 'Search Weazel News',
|
||||
emptyTitle: 'No Articles Found',
|
||||
emptyBody: 'Try another headline, topic, or category.',
|
||||
},
|
||||
states: {
|
||||
loading: 'Loading Weazel News...',
|
||||
emptyTitle: 'No News Yet',
|
||||
emptyBody:
|
||||
'The Weazel News desk has not published any articles here yet.',
|
||||
errorTitle: 'Weazel News Is Unavailable',
|
||||
readOnlyTitle: 'Read-only Access',
|
||||
readOnlyBody:
|
||||
'Your current job can read Weazel News, but cannot manage articles.',
|
||||
noManagedTitle: 'No Editorial Articles',
|
||||
noManagedBody:
|
||||
'Create an article or change the current editorial filter.',
|
||||
},
|
||||
article: {
|
||||
readMore: 'Read Article',
|
||||
byline: 'By {author}',
|
||||
updated: 'Updated {date}',
|
||||
published: 'Published {date}',
|
||||
coverAlt: 'Cover image for {title}',
|
||||
},
|
||||
editorial: {
|
||||
title: 'Editorial Desk',
|
||||
subtitle: 'Create, publish, and maintain Weazel News articles.',
|
||||
newArticle: 'New Article',
|
||||
published: 'Published',
|
||||
drafts: 'Drafts',
|
||||
all: 'All',
|
||||
jobAccess: 'Signed in as {job}',
|
||||
edit: 'Edit Article',
|
||||
delete: 'Delete Article',
|
||||
},
|
||||
composer: {
|
||||
newTitle: 'New Article',
|
||||
editTitle: 'Edit Article',
|
||||
title: 'Headline',
|
||||
titlePlaceholder: 'Write a clear headline',
|
||||
body: 'Article',
|
||||
bodyPlaceholder: 'Write the full story...',
|
||||
category: 'Category',
|
||||
status: 'Publication Status',
|
||||
cover: 'Cover Image',
|
||||
chooseCover: 'Choose from Gallery',
|
||||
changeCover: 'Change Cover',
|
||||
removeCover: 'Remove Cover',
|
||||
coverAlt: 'Selected article cover',
|
||||
publish: 'Publish Article',
|
||||
saveDraft: 'Save Draft',
|
||||
saveChanges: 'Save Changes',
|
||||
statusPublished: 'Published',
|
||||
statusDraft: 'Draft',
|
||||
},
|
||||
delete: {
|
||||
title: 'Delete Article?',
|
||||
body: 'This article will be removed from Weazel News.',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Delete',
|
||||
},
|
||||
feedback: {
|
||||
created: 'Article created.',
|
||||
updated: 'Article updated.',
|
||||
deleted: 'Article deleted.',
|
||||
},
|
||||
accessibility: {
|
||||
articleList: 'Weazel News articles',
|
||||
categoryList: 'News categories',
|
||||
editorialList: 'Editorial articles',
|
||||
openArticle: 'Open {title}',
|
||||
openCategory: 'Open {category}',
|
||||
openEditorial: 'Open the editorial desk',
|
||||
newArticle: 'Create a new article',
|
||||
editArticle: 'Edit {title}',
|
||||
deleteArticle: 'Delete {title}',
|
||||
search: 'Search Weazel News',
|
||||
clearSearch: 'Clear article search',
|
||||
coverPreview: 'Article cover preview',
|
||||
removeCover: 'Remove the selected cover image',
|
||||
status: 'Article status: {status}',
|
||||
},
|
||||
errors: {
|
||||
feature_disabled: 'Weazel News is currently disabled.',
|
||||
invalid_article: 'Enter a valid headline, article, and category.',
|
||||
invalid_draft:
|
||||
'Enter a headline and article text before saving the draft.',
|
||||
invalid_publish: 'Enter a headline and article text before publishing.',
|
||||
invalid_request: 'Check the article details and try again.',
|
||||
invalid_image: 'Choose a valid photo from this phone.',
|
||||
invalid_attachment: 'Choose a valid photo from this phone.',
|
||||
not_authorized: 'Your current job cannot manage Weazel News articles.',
|
||||
article_not_found: 'This article is no longer available.',
|
||||
not_found: 'This article is no longer available.',
|
||||
revision_conflict:
|
||||
'This article changed on another device. Reload it and try again.',
|
||||
rate_limited: 'Too many requests. Try again shortly.',
|
||||
request_failed: 'Weazel News could not complete the request.',
|
||||
default: 'Weazel News is temporarily unavailable.',
|
||||
},
|
||||
},
|
||||
companies: companiesFallbackLocales,
|
||||
crewlink: {
|
||||
name: 'CrewLink',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWeazelNewsStore } from '@/stores/weazel-news'
|
||||
import type {
|
||||
WeazelNewsArticle,
|
||||
WeazelNewsArticleDraft,
|
||||
} from '@/types/weazel-news'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
const article: WeazelNewsArticle = {
|
||||
authorName: 'Jane Doe',
|
||||
body: 'The full article body.',
|
||||
category: 'news',
|
||||
createdAt: 1_754_000_000,
|
||||
excerpt: 'The article excerpt.',
|
||||
id: '7d28b252-0532-4869-9512-e313e34d2fb8',
|
||||
imageMediaId: 12,
|
||||
imageUrl: 'https://media.invalid/weazel/article.webp',
|
||||
publishedAt: 1_754_000_100,
|
||||
revision: 4,
|
||||
status: 'published',
|
||||
title: 'Breaking news',
|
||||
updatedAt: 1_754_000_100,
|
||||
}
|
||||
|
||||
const draft: WeazelNewsArticleDraft = {
|
||||
body: 'Updated body.',
|
||||
category: 'business',
|
||||
imageMediaId: null,
|
||||
status: 'draft',
|
||||
title: 'Updated headline',
|
||||
}
|
||||
|
||||
describe('Weazel News store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads the server-owned management context and category counts', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: {
|
||||
canManage: true,
|
||||
categories: [
|
||||
{ count: 3, id: 'news' },
|
||||
{ count: 1, id: 'jobs' },
|
||||
],
|
||||
jobGradeLabel: 'Editor',
|
||||
jobLabel: 'Weazel News',
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(await store.loadContext()).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:context')
|
||||
expect(store.context?.canManage).toBe(true)
|
||||
expect(store.context?.categories[0]).toEqual({ count: 3, id: 'news' })
|
||||
})
|
||||
|
||||
it('loads and deduplicates appended public pages', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: true, items: [article] },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
hasMore: false,
|
||||
items: [
|
||||
{ ...article, revision: 5, title: 'Canonical headline' },
|
||||
{ ...article, id: 'second-article' },
|
||||
],
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
await store.loadPublic({ category: 'news', search: 'breaking' })
|
||||
await store.loadPublic({
|
||||
append: true,
|
||||
category: 'news',
|
||||
search: 'breaking',
|
||||
})
|
||||
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'weazel-news:list', {
|
||||
category: 'news',
|
||||
offset: 0,
|
||||
search: 'breaking',
|
||||
})
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'weazel-news:list', {
|
||||
category: 'news',
|
||||
offset: 1,
|
||||
search: 'breaking',
|
||||
})
|
||||
expect(store.publicItems).toHaveLength(2)
|
||||
expect(store.publicItems[0]?.title).toBe('Canonical headline')
|
||||
expect(store.publicHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('loads paged management results including drafts', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [{ ...article, status: 'draft' }] },
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(
|
||||
await store.loadManaged('draft', { offset: 20, search: 'city' }),
|
||||
).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:manage-list', {
|
||||
offset: 20,
|
||||
search: 'city',
|
||||
status: 'draft',
|
||||
})
|
||||
expect(store.managedItems[0]?.status).toBe('draft')
|
||||
})
|
||||
|
||||
it('loads a canonical article for public or management detail', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { article },
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(await store.loadArticle(article.id, true)).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:get', {
|
||||
id: article.id,
|
||||
manage: true,
|
||||
})
|
||||
expect(store.selected).toEqual(article)
|
||||
})
|
||||
|
||||
it('normalizes a direct canonical create response', async () => {
|
||||
const created = { ...article, id: 'created-article', revision: 1 }
|
||||
mockNuiCall.mockResolvedValueOnce({ data: created, success: true })
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(await store.create({ ...draft, status: 'published' })).toEqual(
|
||||
created,
|
||||
)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:create', {
|
||||
...draft,
|
||||
status: 'published',
|
||||
})
|
||||
expect(store.managedItems).toEqual([created])
|
||||
expect(store.publicItems).toEqual([created])
|
||||
})
|
||||
|
||||
it('sends the optimistic revision and applies the canonical update', async () => {
|
||||
const canonical = {
|
||||
...article,
|
||||
...draft,
|
||||
excerpt: 'Updated excerpt.',
|
||||
revision: 5,
|
||||
updatedAt: 1_754_100_000,
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { article: canonical },
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
store.publicItems = [article]
|
||||
store.managedItems = [article]
|
||||
store.selected = article
|
||||
|
||||
expect(await store.update(article, draft)).toEqual(canonical)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:update', {
|
||||
...draft,
|
||||
id: article.id,
|
||||
revision: 4,
|
||||
})
|
||||
expect(store.selected).toEqual(canonical)
|
||||
expect(store.managedItems).toEqual([canonical])
|
||||
expect(store.publicItems).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps local articles unchanged after a revision conflict', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'revision_conflict',
|
||||
success: false,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
store.managedItems = [article]
|
||||
|
||||
expect(await store.update(article, draft)).toBeNull()
|
||||
expect(store.managedItems).toEqual([article])
|
||||
expect(store.error).toBe('revision_conflict')
|
||||
})
|
||||
|
||||
it('deletes by id and revision only after server confirmation', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
const store = useWeazelNewsStore()
|
||||
store.publicItems = [article]
|
||||
store.managedItems = [article]
|
||||
store.selected = article
|
||||
|
||||
expect(await store.remove(article)).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:delete', {
|
||||
id: article.id,
|
||||
revision: article.revision,
|
||||
})
|
||||
expect(store.publicItems).toEqual([])
|
||||
expect(store.managedItems).toEqual([])
|
||||
expect(store.selected).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
WeazelNewsArticle,
|
||||
WeazelNewsArticleDraft,
|
||||
WeazelNewsArticleResponse,
|
||||
WeazelNewsArticleSummary,
|
||||
WeazelNewsContext,
|
||||
WeazelNewsListResponse,
|
||||
WeazelNewsManagedListOptions,
|
||||
WeazelNewsManageStatus,
|
||||
WeazelNewsPublicListOptions,
|
||||
} from '@/types/weazel-news'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
type ArticleMutationResponse = WeazelNewsArticle | WeazelNewsArticleResponse
|
||||
|
||||
function readArticle(
|
||||
data: ArticleMutationResponse | undefined,
|
||||
): WeazelNewsArticle | null {
|
||||
if (!data) return null
|
||||
return 'article' in data ? data.article : data
|
||||
}
|
||||
|
||||
function replaceArticle(
|
||||
items: WeazelNewsArticleSummary[],
|
||||
article: WeazelNewsArticleSummary,
|
||||
): WeazelNewsArticleSummary[] {
|
||||
return items.map((item) => (item.id === article.id ? article : item))
|
||||
}
|
||||
|
||||
function mergeArticles(
|
||||
current: WeazelNewsArticleSummary[],
|
||||
incoming: WeazelNewsArticleSummary[],
|
||||
): WeazelNewsArticleSummary[] {
|
||||
const merged = new Map(current.map((article) => [article.id, article]))
|
||||
for (const article of incoming) merged.set(article.id, article)
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export const useWeazelNewsStore = defineStore('weazel-news', {
|
||||
state: () => ({
|
||||
context: null as WeazelNewsContext | null,
|
||||
contextError: '',
|
||||
contextLoadSequence: 0,
|
||||
contextLoading: false,
|
||||
detailError: '',
|
||||
detailLoadSequence: 0,
|
||||
detailLoading: false,
|
||||
error: '',
|
||||
loading: false,
|
||||
managedError: '',
|
||||
managedLoadSequence: 0,
|
||||
managedLoading: false,
|
||||
managedHasMore: false,
|
||||
managedItems: [] as WeazelNewsArticleSummary[],
|
||||
mutating: false,
|
||||
publicHasMore: false,
|
||||
publicError: '',
|
||||
publicLoadSequence: 0,
|
||||
publicLoading: false,
|
||||
publicItems: [] as WeazelNewsArticleSummary[],
|
||||
selected: null as WeazelNewsArticle | null,
|
||||
}),
|
||||
actions: {
|
||||
async loadContext(): Promise<boolean> {
|
||||
const requestSequence = ++this.contextLoadSequence
|
||||
this.contextLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<WeazelNewsContext>('weazel-news:context')
|
||||
if (requestSequence !== this.contextLoadSequence) return false
|
||||
this.contextLoading = false
|
||||
this.loading =
|
||||
this.publicLoading || this.managedLoading || this.detailLoading
|
||||
if (!response.success || !response.data) {
|
||||
this.contextError = response.error ?? 'request_failed'
|
||||
this.error = this.contextError
|
||||
return false
|
||||
}
|
||||
this.context = response.data
|
||||
this.contextError = ''
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async loadPublic(
|
||||
options: WeazelNewsPublicListOptions = {},
|
||||
): Promise<boolean> {
|
||||
const append = options.append ?? false
|
||||
const requestSequence = ++this.publicLoadSequence
|
||||
this.publicLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<WeazelNewsListResponse>(
|
||||
'weazel-news:list',
|
||||
{
|
||||
category: options.category ?? null,
|
||||
offset: options.offset ?? (append ? this.publicItems.length : 0),
|
||||
search: options.search ?? '',
|
||||
},
|
||||
)
|
||||
if (requestSequence !== this.publicLoadSequence) return false
|
||||
this.publicLoading = false
|
||||
this.loading =
|
||||
this.contextLoading || this.managedLoading || this.detailLoading
|
||||
if (!response.success || !response.data) {
|
||||
this.publicError = response.error ?? 'request_failed'
|
||||
this.error = this.publicError
|
||||
return false
|
||||
}
|
||||
this.publicItems = append
|
||||
? mergeArticles(this.publicItems, response.data.items)
|
||||
: response.data.items
|
||||
this.publicHasMore = response.data.hasMore
|
||||
this.publicError = ''
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async loadManaged(
|
||||
status: WeazelNewsManageStatus,
|
||||
options: WeazelNewsManagedListOptions = {},
|
||||
): Promise<boolean> {
|
||||
const append = options.append ?? false
|
||||
const requestSequence = ++this.managedLoadSequence
|
||||
this.managedLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<WeazelNewsListResponse>(
|
||||
'weazel-news:manage-list',
|
||||
{
|
||||
offset: options.offset ?? (append ? this.managedItems.length : 0),
|
||||
search: options.search ?? '',
|
||||
status,
|
||||
},
|
||||
)
|
||||
if (requestSequence !== this.managedLoadSequence) return false
|
||||
this.managedLoading = false
|
||||
this.loading =
|
||||
this.contextLoading || this.publicLoading || this.detailLoading
|
||||
if (!response.success || !response.data) {
|
||||
this.managedError = response.error ?? 'request_failed'
|
||||
this.error = this.managedError
|
||||
return false
|
||||
}
|
||||
this.managedItems = append
|
||||
? mergeArticles(this.managedItems, response.data.items)
|
||||
: response.data.items
|
||||
this.managedHasMore = response.data.hasMore
|
||||
this.managedError = ''
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async loadArticle(id: string, manage = false): Promise<boolean> {
|
||||
const requestSequence = ++this.detailLoadSequence
|
||||
this.detailLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<ArticleMutationResponse>(
|
||||
'weazel-news:get',
|
||||
{
|
||||
id,
|
||||
manage,
|
||||
},
|
||||
)
|
||||
if (requestSequence !== this.detailLoadSequence) return false
|
||||
this.detailLoading = false
|
||||
this.loading =
|
||||
this.contextLoading || this.publicLoading || this.managedLoading
|
||||
const article = response.success ? readArticle(response.data) : null
|
||||
if (!article) {
|
||||
this.detailError = response.error ?? 'request_failed'
|
||||
this.error = this.detailError
|
||||
return false
|
||||
}
|
||||
this.selected = article
|
||||
this.detailError = ''
|
||||
this.publicItems = replaceArticle(this.publicItems, article)
|
||||
this.managedItems = replaceArticle(this.managedItems, article)
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async create(
|
||||
draft: WeazelNewsArticleDraft,
|
||||
): Promise<WeazelNewsArticle | null> {
|
||||
this.publicLoadSequence += 1
|
||||
this.managedLoadSequence += 1
|
||||
this.detailLoadSequence += 1
|
||||
this.publicLoading = false
|
||||
this.managedLoading = false
|
||||
this.detailLoading = false
|
||||
this.loading = this.contextLoading
|
||||
this.mutating = true
|
||||
const response = await nuiCall<ArticleMutationResponse>(
|
||||
'weazel-news:create',
|
||||
draft,
|
||||
)
|
||||
this.mutating = false
|
||||
const article = response.success ? readArticle(response.data) : null
|
||||
if (!article) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return null
|
||||
}
|
||||
this.selected = article
|
||||
this.managedItems = [
|
||||
article,
|
||||
...this.managedItems.filter((item) => item.id !== article.id),
|
||||
]
|
||||
if (article.status === 'published') {
|
||||
this.publicItems = [
|
||||
article,
|
||||
...this.publicItems.filter((item) => item.id !== article.id),
|
||||
]
|
||||
}
|
||||
this.error = ''
|
||||
return article
|
||||
},
|
||||
async update(
|
||||
article: WeazelNewsArticle,
|
||||
draft: WeazelNewsArticleDraft,
|
||||
): Promise<WeazelNewsArticle | null> {
|
||||
this.publicLoadSequence += 1
|
||||
this.managedLoadSequence += 1
|
||||
this.detailLoadSequence += 1
|
||||
this.publicLoading = false
|
||||
this.managedLoading = false
|
||||
this.detailLoading = false
|
||||
this.loading = this.contextLoading
|
||||
this.mutating = true
|
||||
const response = await nuiCall<ArticleMutationResponse>(
|
||||
'weazel-news:update',
|
||||
{
|
||||
...draft,
|
||||
id: article.id,
|
||||
revision: article.revision,
|
||||
},
|
||||
)
|
||||
this.mutating = false
|
||||
const canonical = response.success ? readArticle(response.data) : null
|
||||
if (!canonical) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return null
|
||||
}
|
||||
if (this.selected?.id === canonical.id) this.selected = canonical
|
||||
this.managedItems = replaceArticle(this.managedItems, canonical)
|
||||
this.publicItems =
|
||||
canonical.status === 'published'
|
||||
? [
|
||||
canonical,
|
||||
...this.publicItems.filter((item) => item.id !== canonical.id),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
(right.publishedAt ?? 0) - (left.publishedAt ?? 0),
|
||||
)
|
||||
: this.publicItems.filter((item) => item.id !== canonical.id)
|
||||
this.error = ''
|
||||
return canonical
|
||||
},
|
||||
async remove(article: WeazelNewsArticle): Promise<boolean> {
|
||||
this.publicLoadSequence += 1
|
||||
this.managedLoadSequence += 1
|
||||
this.detailLoadSequence += 1
|
||||
this.publicLoading = false
|
||||
this.managedLoading = false
|
||||
this.detailLoading = false
|
||||
this.loading = this.contextLoading
|
||||
this.mutating = true
|
||||
const response = await nuiCall('weazel-news:delete', {
|
||||
id: article.id,
|
||||
revision: article.revision,
|
||||
})
|
||||
this.mutating = false
|
||||
if (!response.success) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.publicItems = this.publicItems.filter(
|
||||
(item) => item.id !== article.id,
|
||||
)
|
||||
this.managedItems = this.managedItems.filter(
|
||||
(item) => item.id !== article.id,
|
||||
)
|
||||
if (this.selected?.id === article.id) this.selected = null
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -37,6 +37,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'feather'
|
||||
| 'crewlink'
|
||||
| 'companies'
|
||||
| 'weazel-news'
|
||||
|
||||
declare const externalPhoneAppId: unique symbol
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
export const WEAZEL_NEWS_CATEGORY_IDS = [
|
||||
'official',
|
||||
'events',
|
||||
'jobs',
|
||||
'news',
|
||||
'business',
|
||||
] as const
|
||||
|
||||
export const WEAZEL_NEWS_ARTICLE_STATUSES = ['draft', 'published'] as const
|
||||
|
||||
export type WeazelNewsCategoryId = (typeof WEAZEL_NEWS_CATEGORY_IDS)[number]
|
||||
|
||||
export type WeazelNewsArticleStatus =
|
||||
(typeof WEAZEL_NEWS_ARTICLE_STATUSES)[number]
|
||||
|
||||
export type WeazelNewsManageStatus = 'all' | WeazelNewsArticleStatus
|
||||
|
||||
export type WeazelNewsCategorySummary = {
|
||||
count: number
|
||||
id: WeazelNewsCategoryId
|
||||
}
|
||||
|
||||
export type WeazelNewsArticle = {
|
||||
authorName: string
|
||||
body: string
|
||||
category: WeazelNewsCategoryId
|
||||
createdAt: number
|
||||
excerpt: string
|
||||
id: string
|
||||
imageMediaId?: number | null
|
||||
imageUrl?: string | null
|
||||
publishedAt?: number | null
|
||||
revision: number
|
||||
status: WeazelNewsArticleStatus
|
||||
title: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type WeazelNewsArticleSummary = Omit<WeazelNewsArticle, 'body'>
|
||||
|
||||
export type WeazelNewsArticleDraft = {
|
||||
body: string
|
||||
category: WeazelNewsCategoryId
|
||||
imageMediaId: number | null
|
||||
status: WeazelNewsArticleStatus
|
||||
title: string
|
||||
}
|
||||
|
||||
export type WeazelNewsContext = {
|
||||
canManage: boolean
|
||||
categories: WeazelNewsCategorySummary[]
|
||||
jobGradeLabel?: string
|
||||
jobLabel?: string
|
||||
}
|
||||
|
||||
export type WeazelNewsListResponse = {
|
||||
hasMore: boolean
|
||||
items: WeazelNewsArticleSummary[]
|
||||
}
|
||||
|
||||
export type WeazelNewsPublicListOptions = {
|
||||
append?: boolean
|
||||
category?: WeazelNewsCategoryId | null
|
||||
offset?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export type WeazelNewsManagedListOptions = {
|
||||
append?: boolean
|
||||
offset?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export type WeazelNewsArticleResponse = {
|
||||
article: WeazelNewsArticle
|
||||
}
|
||||
@@ -87,6 +87,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
'neon-drop': { enabled: true, sounds: true },
|
||||
citymarkt: { enabled: true, sounds: true },
|
||||
companies: { enabled: true, sounds: true },
|
||||
'weazel-news': { enabled: true, sounds: true },
|
||||
'local-pages': { enabled: true, sounds: true },
|
||||
picstagram: { enabled: true, sounds: true },
|
||||
fliptok: { enabled: true, sounds: true },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user