Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

# Monolithic

A Fullstack manga reading website, full with features. A Predecessor to [alterkai-website](https://github.com/Alterkai/alterkai-website), hence why it's called Alterkai v2 (Codename: Monolithic). Powered by Nuxt 3, PostgreSQL, and Redis.
A Fullstack manga reading website, full with features. A Successor to [alterkai-website](https://github.com/Alterkai/alterkai-website), hence why it's called Alterkai v2 (Codename: Monolithic). Powered by Nuxt 3, PostgreSQL, and Redis.

## Development

Expand All @@ -23,6 +23,10 @@ Install dependencies
npm install
```

Install [Redis v7.0](https://redis.io/downloads/). This code uses Redis v7.0.5.

Install [Postgres v16](https://www.postgresql.org/download/). Make sure to install the same version, as different version is untested and may broke the Website.

Configure .env (or copy from .env.example)
```env
DB_USER =
Expand Down Expand Up @@ -59,11 +63,21 @@ Start the server
- Azure Blob Storage as CDN/Image storage
- Manga and Chapter view trackers using Redis + PgSQL
- Daily Highlights for home (random title, reset daily)
- Comments (Basics)
- Comments (Basics)

### Yet to be implemented
- Comments [Soon]
- Switching image CDN [Soon]
- Local History (UI Implementation of Lastread) [Soon]
- Local History (UI Implementation of Lastread) [Soon]
- Tasks Assignment for Scanlation Staff [Soon]
- Salary Tracking for Scanlation Staff [Soon]
- Popularity Algorithm [Soon]
- SQL seeds [Soon]

- Popularity Algorithm [Soon]
- SQL seeds [Soon]

## Authors

- [@faralha](https://www.github.com/faralha)
Expand Down
11 changes: 5 additions & 6 deletions app.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
<template>
<UApp>
<Navbar v-if="!['/login', '/register'].includes(route.path)" />
<NuxtPage />
<Footer v-if="!['/login', '/register'].includes(route.path)" class="mt-[10rem]" />
</UApp>
<NuxtLayout>
<UApp>
<NuxtPage />
</UApp>
</NuxtLayout>
</template>

<script setup>
const route = useRoute();
const authStore = useAuthStore();

onMounted(async () => {
Expand Down
168 changes: 137 additions & 31 deletions components/Comments/container.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,56 @@
<template>
<div class="flex flex-col gap-2 bg-accented h-[20rem]">
<div v-if="isLoading">
<div class="flex flex-col gap-2 bg-accented h-[25rem]">

<!-- COMMENT INPUT -->
<div class="p-3 flex-shrink-0 w-full">
<div v-if="isLoggedIn == true" class="flex flex-col w-full items-end gap-2">
<UTextarea class="flex-1 w-full" v-model="newComment" placeholder="Add a comment..." />
<UButton @click="submitComment">Submit</UButton>
</div>

<div v-else class="text-center w-full">
<p class="text-sm text-current/50">You must be logged in to comment.</p>
</div>
</div>

<div v-else v-for="data in comments" :key="data.id" class="p-3 flex items-start gap-2">
<UAvatar size="2xl" :src="data.user_avatar" />
<div class="flex flex-col">
<p class="text-sm text-current/80">@{{ data.username }}</p>
<p class="text-xs text-current/50 mb-1">{{ new Date(data.date_added).toLocaleDateString() }}</p>
<p>{{ data.comment }}</p>
<USeparator class="h-1 opacity-50 px-[2rem]" color="primary" />

<!-- COMMENTS CONTAINER -->
<div class="overflow-y-auto flex-1">
<div v-if="pending">
<p>Loading comments...</p>
</div>

<div v-else v-if="comments" v-for="data in comments" :key="data.id"
class="p-3 flex items-start justify-between gap-2">
<div class="flex items-start gap-2 flex-1">
<UAvatar size="2xl" :src="data.user_avatar" icon="i-lucide-circle-user-round"
class="outline outline-old-neutral-500 rounded-xl overflow-hidden" />
<div class="flex flex-col">
<p class="text-md text-current font-semibold">{{ data.username }}</p>
<p class="text-xs text-current/50 mb-1">{{ new Date(data.date_added).toLocaleDateString() }}</p>
<p>{{ data.comment }}</p>
</div>
</div>

<!-- Delete Button -->
<UButton v-if="user?.id === data.user_id.toString()" icon="i-lucide-trash" variant="ghost" color="neutral" size="sm"
@click="deleteComment(data.id)" />
</div>

<div v-if="!comments || comments.length === 0" class="p-3 text-center text-current/50">
No comments yet. Be the first to comment!
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { useAuthStore } from '~/stores/auth';

const authStore = useAuthStore();
const isLoggedIn = computed(() => authStore.isLoggedIn);
const user = computed(() => authStore.user);
const isLoading = ref(false);
const toast = useToast();
const props = defineProps({
Expand All @@ -32,6 +66,8 @@ const props = defineProps({
});
const { manga_id, chapter_id } = props;

const newComment = ref('');

interface Comment {
id: number;
comment: string;
Expand All @@ -41,33 +77,67 @@ interface Comment {
user_avatar: string;
}

let comments = ref<Comment[]>([]);
async function fetchComments() {
// Fetch Comments
const { data: comments, pending, error, refresh } = await useAsyncData<Comment[]>(
`comments-${manga_id}-${chapter_id ?? 'manga'}`,
() => {
const url = chapter_id
? `/api/manga/${manga_id}/${chapter_id}/comments`
: `/api/manga/${manga_id}/comments`;
return $fetch(url);
}, {
default: () => []
}
);

if (error.value) {
toast.add({
title: 'Error',
description: 'Failed to fetch comments.',
color: 'error',
duration: 5000
});
}

// Submit comment
async function submitComment() {
if (!newComment.value.trim()) {
toast.add({
title: 'Error',
description: 'Comment cannot be empty.',
color: 'error',
duration: 3000
});
return;
}

try {
isLoading.value = true;
// Fetch from chapters
if (chapter_id) {
const results = await $fetch<Comment[]>(`/api/manga/${manga_id}/${chapter_id}/comments`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
comments.value = results;
} else {
// Fetch from manga
const results = await $fetch<Comment[]>(`/api/manga/${manga_id}/comments`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
comments.value = results;
}
await $fetch(`/api/manga/${manga_id}/${chapter_id ? chapter_id + '/' : ''}comments`, {
method: 'POST',
body: {
comment: newComment.value
},
headers: {
'Content-Type': 'application/json'
}
});

// Clear the input field
newComment.value = '';
// Fetch updated comments
await refresh();

toast.add({
title: 'Success',
description: 'Comment added successfully.',
color: 'success',
duration: 3000
});
} catch (error) {
toast.add({
title: 'Error',
description: 'Failed to fetch comments.',
description: 'Failed to submit comment.',
color: 'error',
duration: 5000
});
Expand All @@ -76,7 +146,43 @@ async function fetchComments() {
}
}

// Delete Comment function
async function deleteComment(commentId: number) {
if (!isLoggedIn.value) {
toast.add({
title: 'Error',
description: 'You must be logged in to delete comments.',
color: 'error',
duration: 3000
});
return;
}

try {
await $fetch(`/api/manga/${manga_id}/${chapter_id ? chapter_id + '/' : ''}comments/${commentId}`, {
method: 'DELETE'
});

// Refresh comments after deletion
await refresh();

toast.add({
title: 'Success',
description: 'Comment deleted successfully.',
color: 'success',
duration: 3000
});
} catch (error) {
console.error('Failed to delete comment:', error);
toast.add({
title: 'Error',
description: 'Failed to delete comment.',
color: 'error',
duration: 5000
});
}
}

onMounted(() => {
fetchComments();
})
</script>
19 changes: 10 additions & 9 deletions components/Mangacard/home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
<!-- Manga Cover Wrapper -->
<div class="relative w-full aspect-[2/3] overflow-hidden">
<!-- Update Badge -->
<div class="absolute top-0 left-0 bg-red-500 text-white text-xs px-2 py-1 z-10 rounded-br-md font-bold uppercase">
<div v-if="isUp" class="absolute top-0 left-0 bg-red-500 text-white text-xs px-2 py-1 z-10 rounded-br-md font-bold uppercase">
UP
</div>

<!-- Manga Cover Image -->
<NuxtImg :src="data.manga_cover" placeholder="/images/covers/65601c12-d40a-441e-920a-300ae87a2448.jpg"
<NuxtImg :src="data.manga_cover"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" alt="Manga Cover" />

<!-- Title Overlay -->
Expand All @@ -24,10 +24,10 @@
<!-- Chapter Information -->
<div class="p-2 flex-grow">
<p class="font-semibold text-sm truncate">
Ch. {{ parseInt(data.chapter_number) ? parseInt(data.chapter_number) : 0 }}
Ch. {{ parseInt(data.chapter_id) ? parseInt(data.chapter_id) : 0 }}
</p>
<p class="text-xs">
{{ timeAgo(data.chapter_date_added) ? timeAgo(data.chapter_date_added) : 'Unknown Date' }}
{{ timeAgo(data.chapter_date_added) ? timeAgo(data.chapter_date_added) : 'Unknown' }}
</p>
</div>
</div>
Expand All @@ -44,13 +44,14 @@ defineProps({
manga_id: 0,
manga_title: 'Manga Title Placeholder',
manga_cover: 'https://placehold.co/600x850/333/EEE/png?text=No+Cover',
chapter_number: 0,
chapter_id: 0,
chapter_name: 'Latest Chapter',
chapter_date_added: new Date(),
views: 0,
})
},
isUp: {
type: Boolean,
default: true
}
})
</script>

<style scoped></style>
</script>
23 changes: 19 additions & 4 deletions components/Viewer/horizontal.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<template>
<div v-if="data && data.length > 0" class="w-full flex flex-col items-center gap-4">
<div v-if="data && data.length > 0" class="w-full flex flex-col items-center gap-4 h-screen">
<!-- Main Viewer Area -->
<div class="relative w-full max-w-4xl select-none">
<div class="relative w-full max-w-4xl flex-1 overflow-hidden select-none">
<!-- Display the current image -->
<NuxtImg v-if="currentImage" :key="currentImage.id" :src="currentImage.link" class="w-full h-auto rounded-md"
preload :alt="`Image ${currentImage.order}`" />
<NuxtImg v-if="currentImage" :key="currentImage.id" :src="imageSource" class="w-full h-full object-contain rounded-md"
preload :alt="`Image ${currentImage.order}`" @error="handleImageError" :placeholder="'https://placehold.co/500x800/27272a/404040?text=Loading...'" />

<!-- Clickable Navigation Overlays -->
<div class="absolute top-0 left-0 h-full w-1/2 cursor-pointer" title="Previous Page" @click="previousImage"></div>
Expand Down Expand Up @@ -47,6 +47,21 @@ const currentIndex = ref(0);
// Computed property to get the current image object
const currentImage = computed(() => props.data[currentIndex.value]);

// Handle image loading errors
const imageHasError = ref(false);
const imageSource = computed(() => {
if (imageHasError.value || !currentImage.value) {
return 'https://placehold.co/500x800/27272a/404040?text=Image+Not+Available';
}
return currentImage.value.link;
});
const handleImageError = () => {
imageHasError.value = true;
};
watch(currentImage, () => {
imageHasError.value = false; // Reset error state when changing images
})

// Computed properties to check if we are at the beginning or end
const isFirstPage = computed(() => currentIndex.value === 0);
const isLastPage = computed(() => currentIndex.value === props.data.length - 1);
Expand Down
Loading