Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions backend/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"doctrine/orm": "^3.5.0",
"nesbot/carbon": "^3.10.1",
"hyvor/internal": "^3.1.4",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
Expand All @@ -24,8 +26,11 @@
"symfony/http-client": "7.3.*",
"symfony/lock": "7.3.*",
"symfony/messenger": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/uid": "7.3.*",
"symfony/yaml": "7.3.*"
},
Expand Down
190 changes: 109 additions & 81 deletions backend/composer.lock

Large diffs are not rendered by default.

19 changes: 16 additions & 3 deletions backend/src/Api/App/Controller/CollectionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
use App\Api\App\Object\CollectionObject;
use App\Api\App\Object\PublicationObject;
use App\Service\Collection\CollectionService;

use Symfony\Component\HttpFoundation\Request;
use App\Api\App\Authorization\AuthorizationListener;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use App\Api\App\Input\AddCollectionInput;

class CollectionController extends AbstractController
{
Expand Down Expand Up @@ -55,5 +55,18 @@ public function getCollection(string $slug, Request $request): JsonResponse
]);
}

#[Route('/collections', methods: ['POST'])]
public function createCollection(Request $request, #[MapRequestPayload] AddCollectionInput $payload): JsonResponse
{
$user = AuthorizationListener::getUser($request);

$name = trim($payload->name);
$isPublic = $payload->is_public;

$collection = $this->collectionService->createCollection($user->id, $name, $isPublic);

}
return $this->json([
'collection' => new CollectionObject($collection, $user->id),
]);
}
}
19 changes: 9 additions & 10 deletions backend/src/Api/App/Controller/PublicationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Api\App\Controller;

use App\Api\App\Object\PublicationObject;
use App\Api\App\Authorization\AuthorizationListener;
use App\Service\Publication\PublicationService;
use App\Service\Collection\CollectionService;
Expand Down Expand Up @@ -65,6 +66,7 @@ public function addPublication(
$user = AuthorizationListener::getUser($request);
$collectionSlug = trim($payload->collection_slug);
$url = trim($payload->url);
$title = trim($payload->title);

$collection = $this->collectionService->findBySlug($collectionSlug);
if (!$collection) {
Expand All @@ -88,28 +90,25 @@ public function addPublication(
$normalizedUrl = $inspection['final_url'];
$publication = $this->publicationService->findByUrl($normalizedUrl);
$created = false;
$attached = false;

if (!$publication) {
$publication = $this->publicationService->createPublication($collection, $inspection);
$created = true;
$attached = true;

$status = Response::HTTP_CREATED;
} else {
$attached = $this->publicationService->attachToCollectionIfMissing($publication, $collection);

if ($attached) {
$publication->setIsFetching(true);
$this->em->flush();
$this->messageBus->dispatch(new ProcessFeedMessage($publication->getId()));
}

$status = Response::HTTP_OK;
}

if ($created || $attached) {
$publication->setIsFetching(true);
$this->em->flush();
$this->messageBus->dispatch(new ProcessFeedMessage($publication->getId()));
}

return $this->json([
'publication' => new \App\Api\App\Object\PublicationObject($publication),
'publication' => new PublicationObject($publication),
'created' => $created,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need these created and attached booleans? Within the current codebase, I don't see any instance of it being used. Shall we remove it @supun-io?

'attached' => $attached,
], $status);
Expand Down
14 changes: 14 additions & 0 deletions backend/src/Api/App/Input/AddCollectionInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Api\App\Input;

use Symfony\Component\Validator\Constraints as Assert;

class AddCollectionInput
{
#[Assert\NotBlank]
public string $name = '';

#[Assert\Type('bool')]
public bool $is_public = false;
}
2 changes: 1 addition & 1 deletion backend/src/Api/App/Object/CollectionObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ public function __construct(Collection $collection, ?int $currentUserId = null)
$this->name = $collection->getName();
$this->slug = $collection->getSlug();
$this->is_public = $collection->isPublic();
$this->is_owner = $currentUserId ? $collection->getHyvorUserId() === $currentUserId : false;
$this->is_owner = $currentUserId && $collection->getHyvorUserId() === $currentUserId;
}
}
19 changes: 5 additions & 14 deletions backend/src/Repository/CollectionUserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,12 @@ public function __construct(ManagerRegistry $registry)
parent::__construct($registry, CollectionUser::class);
}

/**
* @return CollectionUser|null
*/
public function findUserCollectionAccess(int $hyvorUserId, int $collectionId): ?CollectionUser
public function getCollectionUserWithAccess(int $hyvorUserId, int $collectionId): ?CollectionUser
{
$result = $this->createQueryBuilder('cu')
->andWhere('cu.hyvorUserId = :hyvorUserId')
->andWhere('cu.collection = :collectionId')
->setParameter('hyvorUserId', $hyvorUserId)
->setParameter('collectionId', $collectionId)
->getQuery()
->getOneOrNullResult();

assert($result instanceof CollectionUser || $result === null);
return $result;
return $this->findOneBy([
'hyvorUserId' => $hyvorUserId,
'collection' => $collectionId,
]);
}

}
4 changes: 2 additions & 2 deletions backend/src/Service/Collection/CollectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ public function hasUserReadAccess(int $hyvorUserId, Collection $collection): boo
return true;
}

$collectionUser = $this->getCollectionUserRepository()->findUserCollectionAccess(
$collectionUser = $this->getCollectionUserRepository()->getCollectionUserWithAccess(
$hyvorUserId,
$collection->getId()
);
Expand All @@ -165,7 +165,7 @@ public function hasUserWriteAccess(int $hyvorUserId, Collection $collection): bo
return true;
}

$collectionUser = $this->getCollectionUserRepository()->findUserCollectionAccess(
$collectionUser = $this->getCollectionUserRepository()->getCollectionUserWithAccess(
$hyvorUserId,
$collection->getId()
);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/Service/Opml/OpmlService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function import(string $content, int $hyvorUserId): void
if ($child->nodeType === XML_ELEMENT_NODE && $child->tagName === 'outline') {
$publicationUrl = $child->getAttribute('xmlUrl');
$inspection = $this->fetchService->inspectFeed($publicationUrl);
$this->publicationService->createPublication($collection, $inspection);
$this->publicationService->addPublication($collection, $inspection);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/Service/Publication/PublicationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function getPublicationsFromCollection(Collection $collection): array
return $publications;
}

public function createPublication(Collection $collection, array $inspection): Publication
public function addPublication(Collection $collection, array $inspection): Publication
{
$url = $inspection['final_url'];
$feed = $inspection['feed'];
Expand Down
81 changes: 78 additions & 3 deletions frontend/src/routes/app/(reader)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import IconChevronDown from '@hyvor/icons/IconChevronDown';
import IconBoxArrowUpRight from '@hyvor/icons/IconBoxArrowUpRight';
import IconPlus from '@hyvor/icons/IconPlus';
import { Button, Dropdown, ActionList, ActionListItem, Loader, Modal, TextInput } from '@hyvor/design/components';
import { Button, Dropdown, ActionList, ActionListItem, Loader, Modal, TextInput, Switch } from '@hyvor/design/components';
import {
collections,
publications,
Expand All @@ -26,6 +26,10 @@
let showCollections = $state(false);
let showAddPublicationModal = $state(false);
let rssUrl = $state('');
let publicationTitle = $state('');
let showCreateCollectionModal = $state(false);
let collectionName = $state('');
let collectionIsPublic = $state(false);
let selectedItem: Item | null = $state(null);
let currentItemIndex = $derived(
selectedItem ? $items.findIndex(item => item.id === selectedItem!.id) : -1
Expand All @@ -38,6 +42,22 @@
goto(`/app/${collection.slug}`);
}

async function handleCreateCollection() {
const trimmed = collectionName.trim();
if (!trimmed) return;
try {
const res = await api.post('/collections', { name: trimmed, is_public: collectionIsPublic });
const created: Collection = res.collection;
$collections = [...$collections, created];
showCreateCollectionModal = false;
collectionName = '';
collectionIsPublic = false;
goto(`/app/${created.slug}`);
} catch (e) {
console.error('Failed to create collection', e);
}
}

function selectPublication(publication?: Publication) {
if (publication) {
goto(`/app/${$page.params.collection_slug}/${publication.slug}`);
Expand Down Expand Up @@ -127,14 +147,16 @@
}
const res = await api.post('/publications', {
collection_slug: collectionSlug,
url: value
url: value,
title: publicationTitle.trim(),
});
const exists = $publications.find(p => p.slug === res.publication.slug);
if (!exists) {
publications.set([...$publications, res.publication]);
}
showAddPublicationModal = false;
rssUrl = '';
publicationTitle = '';
} catch (e) {
console.error('Failed to add publication', e);
addPublicationError = e instanceof Error ? e.message : 'Failed to add publication';
Expand Down Expand Up @@ -184,6 +206,9 @@
{collection.name}
</ActionListItem>
{/each}
<ActionListItem on:select={() => { showCreateCollectionModal = true; showCollections = false; }}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Use a Button instead

+ Create collection
</ActionListItem>
</ActionList>
{/snippet}
</Dropdown>
Expand Down Expand Up @@ -238,7 +263,7 @@
{/if}
</div>
<div class="publications-footer">
<Button class="add-publication-button" on:click={() => { rssUrl = ''; addPublicationError = null; showAddPublicationModal = true; }}>
<Button class="add-publication-button" on:click={() => { rssUrl = ''; publicationTitle = ''; addPublicationError = null; showAddPublicationModal = true; }}>
{#snippet start()}
<IconPlus size={12} />
{/snippet}
Expand Down Expand Up @@ -336,6 +361,37 @@
</div>
</main>

<Modal
bind:show={showCreateCollectionModal}
size="small"
title="Create Collection"
closeOnOutsideClick={true}
closeOnEscape={true}
footer={{
cancel: { text: 'Cancel', props: { color: 'input' } },
confirm: { text: 'Create', props: { disabled: !collectionName.trim() } }
}}
on:cancel={() => { showCreateCollectionModal = false; }}
on:confirm={handleCreateCollection}
>
<div class="modal-body">
<TextInput
id="collectionName"
type="text"
placeholder="My collection"
autofocus
bind:value={collectionName}
on:keydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && collectionName.trim()) {
handleCreateCollection();
}
}}
/>
<Switch id="collectionPublic" bind:checked={collectionIsPublic}>
Public
</Switch>
</div>
</Modal>

<Modal
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Duplicate

bind:show={showAddPublicationModal}
Expand All @@ -355,6 +411,7 @@
{#if addPublicationError}
<div class="error-text">{addPublicationError}</div>
{/if}

<TextInput
id="rssUrl"
type="url"
Expand All @@ -368,8 +425,26 @@
}}
disabled={addingPublication}
/>

<TextInput
id="publicationTitle"
type="text"
placeholder="Publication Title"
bind:value={publicationTitle}
on:keydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && publicationTitle.trim()) {
handleAdd();
}
}}
/>
</div>

{#snippet footer()}
<div class="modal-footer">
<Button disabled={!isValidUrl(rssUrl) || !publicationTitle.trim()} on:click={handleAdd}>Add</Button>
<Button color="input" on:click={() => { showAddPublicationModal = false; }}>Cancel</Button>
</div>
{/snippet}

</Modal>

Expand Down