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
22 changes: 22 additions & 0 deletions app/Http/Controllers/HomeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Http\Controllers;

use App\Services\HomeStatisticsService;
use Inertia\Inertia;

class HomeController extends Controller
{
public function __construct(
protected HomeStatisticsService $homeStatistics
) {}

public function show()
{
$stats = $this->homeStatistics->getStatistics();

return Inertia::render('Home',
$stats
);
}
}
55 changes: 55 additions & 0 deletions app/Services/HomeStatisticsService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App\Services;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class HomeStatisticsService
{
public function __construct() {}

public function getPublicUrl(?string $path): ?string
{
return $path ? Storage::disk('public')->url($path) : null;
}

private function resourceTop(): Collection
{
return DB::table('computer_science_resources')
->whereNotNull('image_path')
->limit(10)
->get()
->map(fn ($res) => [
'id' => $res->id,
'image_url' => $this->getPublicUrl($res->image_path),
]);
}

private function resourcesCount(): int
{
return DB::table('computer_science_resources')->count();
}

private function topTopics(): Collection
{
return DB::table('tag_frequencies')->where('type', 'topics_tags')
->orderByDesc('count')->limit(10)->get();
}

private function topicsCount(): int
{
return DB::table('tag_frequencies')->where('type', 'topics_tags')->count();
}

public function getStatistics()
{
return [
'resources_top' => $this->resourceTop(),
'resources_count' => $this->resourcesCount(),
'topics_count' => $this->topicsCount(),
'topics_top' => $this->topTopics(),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ public function up(): void

public function down(): void
{
// Rollback: rename back and revert type
// Keep only the first value from the SET
DB::statement("
UPDATE computer_science_resources
SET difficulties = SUBSTRING_INDEX(difficulties, ',', 1)
WHERE difficulties IS NOT NULL AND difficulties != ''
");

DB::statement("
ALTER TABLE computer_science_resources
CHANGE difficulties difficulty ENUM(
Expand Down
4 changes: 2 additions & 2 deletions resources/js/Components/ApplicationHeaderLogo.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<template>
<img src="/images/LogoTitle.svg" alt="Computer Science Resources" class="h-10 sm:h-12 w-auto block dark:hidden" />
<img src="/images/LogoTitleDark.svg" alt="Computer Science Resources" class="h-10 sm:h-12 w-auto hidden dark:block" />
<img src="/images/LogoTitle.svg" alt="Computer Science Resources" class="h-7 sm:h-12 w-auto block dark:hidden" />
<img src="/images/LogoTitleDark.svg" alt="Computer Science Resources" class="h-7 sm:h-12 w-auto hidden dark:block" />
</template>
217 changes: 217 additions & 0 deletions resources/js/Components/CoverflowGallery.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@

<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'

const props = defineProps({
images: { type: Array, required: true }, // array of string URLs
autoplay: { type: [Boolean, Number], default: true }, // enable by default
autoSpeed: { type: Number, default: 0.06 }, // slides per second
loop: { type: Boolean, default: true },
centerScale: { type: Number, default: 1 },
sideScale: { type: Number, default: 0.9 },
spacing: { type: Number, default: 220 },
height: { type: [Number, String], default: 360 }
})

const current = ref(0)
const root = ref(null)
const containerWidth = ref(0)
let rafId = null
let lastTs = 0
let roRef = null

// compute slide width so exactly three slides can fit comfortably
const slideGap = 14 // px gap between slides (slightly smaller gap)
const slideWidthPx = computed(() => {
const w = containerWidth.value || 0
const numericHeight = typeof props.height === 'number' ? props.height : parseFloat(String(props.height)) || 360
if (!w) return Math.min(numericHeight, 360)
// More conservative calculation: start from a strict one-third then reserve
// extra gap space so three slides fit with breathing room.
const rawThird = Math.floor(w / 3)
const reserved = Math.ceil(slideGap * 1.8)
const raw = Math.max(48, rawThird - reserved)
// ensure we don't exceed the visual height or become tiny
const clamped = Math.max(64, Math.min(raw, numericHeight))
return clamped
})

const spacingPx = computed(() => {
return slideWidthPx.value + slideGap
})

const styleRoot = computed(() => ({
'--height': typeof props.height === 'number' ? `${props.height}px` : props.height,
'--slide-w': `${slideWidthPx.value}px`,
'--gap': `${slideGap}px`
}))

const tick = (ts) => {
const n = props.images.length
if (!props.autoplay || n <= 1) { pause(); return }
if (!lastTs) lastTs = ts
const dt = (ts - lastTs) / 1000
lastTs = ts
const nextVal = current.value + (props.autoSpeed * dt)
if (props.loop && n > 0) {
current.value = ((nextVal % n) + n) % n
} else {
current.value = Math.min(n - 1, nextVal)
}
rafId = requestAnimationFrame(tick)
}

const play = () => {
if (!props.autoplay) return
cancelAnimationFrame(rafId)
lastTs = 0
rafId = requestAnimationFrame(tick)
}
const pause = () => { cancelAnimationFrame(rafId); rafId = null }

const clampIndex = (i) => {
const n = props.images.length
if (props.loop) {
return ((i % n) + n) % n
}
return Math.max(0, Math.min(n - 1, i))
}

// (spacingPx is computed above as slideWidth + gap)

const slideStyle = (i) => {
const n = props.images.length
let d = i - current.value
if (props.loop) {
if (d > n / 2) d -= n
if (d < -n / 2) d += n
}
const absd = Math.abs(d)

const rotateY = 0
const translateX = d * spacingPx.value
const translateZ = 0

// Smooth scale and opacity based on distance to the current index
const scale = props.sideScale + (props.centerScale - props.sideScale) * Math.max(0, 1 - Math.min(absd, 1))

// Center fully visible; neighbors fade to 0.7 at distance 1; beyond 1 -> 0
// Show center and immediate neighbors strongly.
// Also show second neighbors faintly so a third image is visible when layout clips.
let opacity = 0
if (absd <= 1) {
opacity = 1 - 0.3 * absd
} else if (absd <= 2) {
// second neighbor: subtle presence
opacity = 0.25 - 0.08 * (absd - 1)
} else {
opacity = 0
}

const transform = `translate(-50%, -50%) translateX(${translateX}px) translateZ(${translateZ}px) rotateY(${rotateY}deg) scale(${scale})`
return {
transform,
opacity,
zIndex: 1000 - Math.round(absd * 10),
pointerEvents: opacity < 0.02 ? 'none' : 'auto'
}
}

let measure
onMounted(() => {
// measure container width and listen for resizes
measure = () => { containerWidth.value = root.value ? root.value.clientWidth : 0 }
measure()
window.addEventListener('resize', measure)
// observe the root for layout changes (images/font load)
roRef = new ResizeObserver(measure)
if (root.value) roRef.observe(root.value)
play()
})

onBeforeUnmount(() => {
pause()
window.removeEventListener('resize', measure)
if (roRef) {
try { roRef.disconnect() } catch (e) {}
roRef = null
}
})

watch(() => props.autoplay, () => { pause(); play() })
</script>

<template>
<div class="coverflow-root" ref="root" :style="styleRoot">
<div class="coverflow-viewport" :style="{ '--count': images.length }">
<div
v-for="(url, i) in images"
:key="i"
class="coverflow-slide"
:class="{ active: i === current }"
:style="slideStyle(i)"
>
<img :src="url" :alt="`image ${i+1}`" draggable="false" />
</div>
</div>
</div>

</template>

<style scoped>
.coverflow-root {
--height: 360px;
position: relative;
height: var(--height);
user-select: none;
overflow: hidden;
margin: 0 auto;
max-width: 1100px;
width: 100%;
}
.coverflow-viewport {
height: 100%;
width: 100%;
position: relative;
transform-style: preserve-3d;
display: flex;
align-items: center;
justify-content: center;
/* fade edges gently so second neighbors remain visible */
-webkit-mask-image: linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1) 6%, rgba(0,0,0,1) 94%, rgba(0,0,0,0));
mask-image: linear-gradient(to right, rgba(0,0,0,0), rgba(0,0,0,1) 6%, rgba(0,0,0,1) 94%, rgba(0,0,0,0));
}
.coverflow-slide {
position: absolute;
/* width driven by JS via --slide-w; fallback to height-based sizing */
width: var(--slide-w, min(calc(var(--height) * 1.1), 92vw));
height: var(--height);
left: 50%;
top: 50%;
transition: transform 0s linear, opacity 400ms ease;
border-radius: 12px;
box-shadow: 0 18px 40px rgba(10,10,20,0.35);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #111;
}
.coverflow-slide img{
width: 100%;
height: 100%;
object-fit: cover;
display: block;
-webkit-user-drag: none;
}
.coverflow-slide.active{
box-shadow: 0 30px 60px rgba(10,10,20,0.5);
}
/* controls removed for a minimalist conveyor style */

@media (max-width: 900px){
}
@media (max-width: 480px){
.coverflow-root { --height: 200px }
}
</style>
2 changes: 1 addition & 1 deletion resources/js/Components/Navigation/Navbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const { isDark, toggleDark } = useDarkMode();
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<Link :href="route('resources.index')">
<Link :href="route('home.show')">
<ApplicationHeaderLogo class="block h-9 w-auto max-w-[250px] sm:max-w-64 md:max-w-72 lg:max-w-80 xl:max-w-96 mr-2" />
</Link>
</div>
Expand Down
Loading