Skip to content

Commit 3f54679

Browse files
AllanKoderCopilot
andauthored
Home page (#39)
* Home Page * Apply automatic changes * Update database/migrations/2025_09_06_223541_change_computer_science_resources_difficulty_to_set.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * nits --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 9015e8c commit 3f54679

8 files changed

Lines changed: 559 additions & 6 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Services\HomeStatisticsService;
6+
use Inertia\Inertia;
7+
8+
class HomeController extends Controller
9+
{
10+
public function __construct(
11+
protected HomeStatisticsService $homeStatistics
12+
) {}
13+
14+
public function show()
15+
{
16+
$stats = $this->homeStatistics->getStatistics();
17+
18+
return Inertia::render('Home',
19+
$stats
20+
);
21+
}
22+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace App\Services;
4+
5+
use Illuminate\Support\Collection;
6+
use Illuminate\Support\Facades\DB;
7+
use Illuminate\Support\Facades\Storage;
8+
9+
class HomeStatisticsService
10+
{
11+
public function __construct() {}
12+
13+
public function getPublicUrl(?string $path): ?string
14+
{
15+
return $path ? Storage::disk('public')->url($path) : null;
16+
}
17+
18+
private function resourceTop(): Collection
19+
{
20+
return DB::table('computer_science_resources')
21+
->whereNotNull('image_path')
22+
->limit(10)
23+
->get()
24+
->map(fn ($res) => [
25+
'id' => $res->id,
26+
'image_url' => $this->getPublicUrl($res->image_path),
27+
]);
28+
}
29+
30+
private function resourcesCount(): int
31+
{
32+
return DB::table('computer_science_resources')->count();
33+
}
34+
35+
private function topTopics(): Collection
36+
{
37+
return DB::table('tag_frequencies')->where('type', 'topics_tags')
38+
->orderByDesc('count')->limit(10)->get();
39+
}
40+
41+
private function topicsCount(): int
42+
{
43+
return DB::table('tag_frequencies')->where('type', 'topics_tags')->count();
44+
}
45+
46+
public function getStatistics()
47+
{
48+
return [
49+
'resources_top' => $this->resourceTop(),
50+
'resources_count' => $this->resourcesCount(),
51+
'topics_count' => $this->topicsCount(),
52+
'topics_top' => $this->topTopics(),
53+
];
54+
}
55+
}

database/migrations/2025_09_06_223541_change_computer_science_resources_difficulty_to_set.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ public function up(): void
2222

2323
public function down(): void
2424
{
25-
// Rollback: rename back and revert type
25+
// Keep only the first value from the SET
26+
DB::statement("
27+
UPDATE computer_science_resources
28+
SET difficulties = SUBSTRING_INDEX(difficulties, ',', 1)
29+
WHERE difficulties IS NOT NULL AND difficulties != ''
30+
");
31+
2632
DB::statement("
2733
ALTER TABLE computer_science_resources
2834
CHANGE difficulties difficulty ENUM(
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
<template>
2-
<img src="/images/LogoTitle.svg" alt="Computer Science Resources" class="h-10 sm:h-12 w-auto block dark:hidden" />
3-
<img src="/images/LogoTitleDark.svg" alt="Computer Science Resources" class="h-10 sm:h-12 w-auto hidden dark:block" />
2+
<img src="/images/LogoTitle.svg" alt="Computer Science Resources" class="h-7 sm:h-12 w-auto block dark:hidden" />
3+
<img src="/images/LogoTitleDark.svg" alt="Computer Science Resources" class="h-7 sm:h-12 w-auto hidden dark:block" />
44
</template>
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
2+
<script setup>
3+
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
4+
5+
const props = defineProps({
6+
images: { type: Array, required: true }, // array of string URLs
7+
autoplay: { type: [Boolean, Number], default: true }, // enable by default
8+
autoSpeed: { type: Number, default: 0.06 }, // slides per second
9+
loop: { type: Boolean, default: true },
10+
centerScale: { type: Number, default: 1 },
11+
sideScale: { type: Number, default: 0.9 },
12+
spacing: { type: Number, default: 220 },
13+
height: { type: [Number, String], default: 360 }
14+
})
15+
16+
const current = ref(0)
17+
const root = ref(null)
18+
const containerWidth = ref(0)
19+
let rafId = null
20+
let lastTs = 0
21+
let roRef = null
22+
23+
// compute slide width so exactly three slides can fit comfortably
24+
const slideGap = 14 // px gap between slides (slightly smaller gap)
25+
const slideWidthPx = computed(() => {
26+
const w = containerWidth.value || 0
27+
const numericHeight = typeof props.height === 'number' ? props.height : parseFloat(String(props.height)) || 360
28+
if (!w) return Math.min(numericHeight, 360)
29+
// More conservative calculation: start from a strict one-third then reserve
30+
// extra gap space so three slides fit with breathing room.
31+
const rawThird = Math.floor(w / 3)
32+
const reserved = Math.ceil(slideGap * 1.8)
33+
const raw = Math.max(48, rawThird - reserved)
34+
// ensure we don't exceed the visual height or become tiny
35+
const clamped = Math.max(64, Math.min(raw, numericHeight))
36+
return clamped
37+
})
38+
39+
const spacingPx = computed(() => {
40+
return slideWidthPx.value + slideGap
41+
})
42+
43+
const styleRoot = computed(() => ({
44+
'--height': typeof props.height === 'number' ? `${props.height}px` : props.height,
45+
'--slide-w': `${slideWidthPx.value}px`,
46+
'--gap': `${slideGap}px`
47+
}))
48+
49+
const tick = (ts) => {
50+
const n = props.images.length
51+
if (!props.autoplay || n <= 1) { pause(); return }
52+
if (!lastTs) lastTs = ts
53+
const dt = (ts - lastTs) / 1000
54+
lastTs = ts
55+
const nextVal = current.value + (props.autoSpeed * dt)
56+
if (props.loop && n > 0) {
57+
current.value = ((nextVal % n) + n) % n
58+
} else {
59+
current.value = Math.min(n - 1, nextVal)
60+
}
61+
rafId = requestAnimationFrame(tick)
62+
}
63+
64+
const play = () => {
65+
if (!props.autoplay) return
66+
cancelAnimationFrame(rafId)
67+
lastTs = 0
68+
rafId = requestAnimationFrame(tick)
69+
}
70+
const pause = () => { cancelAnimationFrame(rafId); rafId = null }
71+
72+
const clampIndex = (i) => {
73+
const n = props.images.length
74+
if (props.loop) {
75+
return ((i % n) + n) % n
76+
}
77+
return Math.max(0, Math.min(n - 1, i))
78+
}
79+
80+
// (spacingPx is computed above as slideWidth + gap)
81+
82+
const slideStyle = (i) => {
83+
const n = props.images.length
84+
let d = i - current.value
85+
if (props.loop) {
86+
if (d > n / 2) d -= n
87+
if (d < -n / 2) d += n
88+
}
89+
const absd = Math.abs(d)
90+
91+
const rotateY = 0
92+
const translateX = d * spacingPx.value
93+
const translateZ = 0
94+
95+
// Smooth scale and opacity based on distance to the current index
96+
const scale = props.sideScale + (props.centerScale - props.sideScale) * Math.max(0, 1 - Math.min(absd, 1))
97+
98+
// Center fully visible; neighbors fade to 0.7 at distance 1; beyond 1 -> 0
99+
// Show center and immediate neighbors strongly.
100+
// Also show second neighbors faintly so a third image is visible when layout clips.
101+
let opacity = 0
102+
if (absd <= 1) {
103+
opacity = 1 - 0.3 * absd
104+
} else if (absd <= 2) {
105+
// second neighbor: subtle presence
106+
opacity = 0.25 - 0.08 * (absd - 1)
107+
} else {
108+
opacity = 0
109+
}
110+
111+
const transform = `translate(-50%, -50%) translateX(${translateX}px) translateZ(${translateZ}px) rotateY(${rotateY}deg) scale(${scale})`
112+
return {
113+
transform,
114+
opacity,
115+
zIndex: 1000 - Math.round(absd * 10),
116+
pointerEvents: opacity < 0.02 ? 'none' : 'auto'
117+
}
118+
}
119+
120+
let measure
121+
onMounted(() => {
122+
// measure container width and listen for resizes
123+
measure = () => { containerWidth.value = root.value ? root.value.clientWidth : 0 }
124+
measure()
125+
window.addEventListener('resize', measure)
126+
// observe the root for layout changes (images/font load)
127+
roRef = new ResizeObserver(measure)
128+
if (root.value) roRef.observe(root.value)
129+
play()
130+
})
131+
132+
onBeforeUnmount(() => {
133+
pause()
134+
window.removeEventListener('resize', measure)
135+
if (roRef) {
136+
try { roRef.disconnect() } catch (e) {}
137+
roRef = null
138+
}
139+
})
140+
141+
watch(() => props.autoplay, () => { pause(); play() })
142+
</script>
143+
144+
<template>
145+
<div class="coverflow-root" ref="root" :style="styleRoot">
146+
<div class="coverflow-viewport" :style="{ '--count': images.length }">
147+
<div
148+
v-for="(url, i) in images"
149+
:key="i"
150+
class="coverflow-slide"
151+
:class="{ active: i === current }"
152+
:style="slideStyle(i)"
153+
>
154+
<img :src="url" :alt="`image ${i+1}`" draggable="false" />
155+
</div>
156+
</div>
157+
</div>
158+
159+
</template>
160+
161+
<style scoped>
162+
.coverflow-root {
163+
--height: 360px;
164+
position: relative;
165+
height: var(--height);
166+
user-select: none;
167+
overflow: hidden;
168+
margin: 0 auto;
169+
max-width: 1100px;
170+
width: 100%;
171+
}
172+
.coverflow-viewport {
173+
height: 100%;
174+
width: 100%;
175+
position: relative;
176+
transform-style: preserve-3d;
177+
display: flex;
178+
align-items: center;
179+
justify-content: center;
180+
/* fade edges gently so second neighbors remain visible */
181+
-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));
182+
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));
183+
}
184+
.coverflow-slide {
185+
position: absolute;
186+
/* width driven by JS via --slide-w; fallback to height-based sizing */
187+
width: var(--slide-w, min(calc(var(--height) * 1.1), 92vw));
188+
height: var(--height);
189+
left: 50%;
190+
top: 50%;
191+
transition: transform 0s linear, opacity 400ms ease;
192+
border-radius: 12px;
193+
box-shadow: 0 18px 40px rgba(10,10,20,0.35);
194+
overflow: hidden;
195+
display: flex;
196+
align-items: center;
197+
justify-content: center;
198+
background: #111;
199+
}
200+
.coverflow-slide img{
201+
width: 100%;
202+
height: 100%;
203+
object-fit: cover;
204+
display: block;
205+
-webkit-user-drag: none;
206+
}
207+
.coverflow-slide.active{
208+
box-shadow: 0 30px 60px rgba(10,10,20,0.5);
209+
}
210+
/* controls removed for a minimalist conveyor style */
211+
212+
@media (max-width: 900px){
213+
}
214+
@media (max-width: 480px){
215+
.coverflow-root { --height: 200px }
216+
}
217+
</style>

resources/js/Components/Navigation/Navbar.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const { isDark, toggleDark } = useDarkMode();
2626
<div class="flex">
2727
<!-- Logo -->
2828
<div class="shrink-0 flex items-center">
29-
<Link :href="route('resources.index')">
29+
<Link :href="route('home.show')">
3030
<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" />
3131
</Link>
3232
</div>

0 commit comments

Comments
 (0)