scaffold blog_portal vue 3 + pinia + cypress frontend
Mirrors youngstartup/frontend/startups_portal scaffolding: Vite, Vue 3 (composition API + script setup), TypeScript strict, Pinia, Vue Router 5, oxlint + eslint + oxfmt, and Cypress with db:reset / db:seed tasks. Views and the auth store are stubs filled in by the next branches; routes and the header chrome are wired so the build passes.
This commit is contained in:
parent
6f95a5b7b8
commit
568dc4aabe
27 changed files with 8376 additions and 0 deletions
119
frontend/blog_portal/src/App.vue
Normal file
119
frontend/blog_portal/src/App.vue
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script setup lang="ts">
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<AppHeader />
|
||||
<main class="app-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
--color-text: #0a0a0a;
|
||||
--color-secondary: #6b6b6b;
|
||||
--color-border: #e5e5e5;
|
||||
--color-accent: #0062ff;
|
||||
--color-bg: #fff;
|
||||
--color-button: #000;
|
||||
--color-button-hover: #222;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="search"] {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-button);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--color-button-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
77
frontend/blog_portal/src/components/AppHeader.vue
Normal file
77
frontend/blog_portal/src/components/AppHeader.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script setup lang="ts">
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
router.push({ name: "home" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<router-link :to="{ name: 'home' }">TIDE</router-link>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<template v-if="auth.user">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'profile',
|
||||
params: { displayName: auth.user.displayName },
|
||||
}"
|
||||
>
|
||||
{{ auth.user.displayName }}
|
||||
</router-link>
|
||||
<button class="secondary" @click="handleLogout">Log out</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link :to="{ name: 'login' }">Log in</router-link>
|
||||
<router-link :to="{ name: 'signup' }" class="signup-link"> Sign up </router-link>
|
||||
</template>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
background: var(--color-button);
|
||||
color: #fff !important;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.signup-link:hover {
|
||||
background: var(--color-button-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
12
frontend/blog_portal/src/main.ts
Normal file
12
frontend/blog_portal/src/main.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
app.mount("#app");
|
||||
83
frontend/blog_portal/src/router/index.ts
Normal file
83
frontend/blog_portal/src/router/index.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomePage from "@/views/HomePage.vue";
|
||||
import LoginPage from "@/views/LoginPage.vue";
|
||||
import SignupPage from "@/views/SignupPage.vue";
|
||||
import CheckEmailPage from "@/views/CheckEmailPage.vue";
|
||||
import ConfirmEmailPage from "@/views/ConfirmEmailPage.vue";
|
||||
import ProfilePage from "@/views/ProfilePage.vue";
|
||||
import PostPage from "@/views/PostPage.vue";
|
||||
import NewPostPage from "@/views/NewPostPage.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: "/", name: "home", component: HomePage },
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginPage,
|
||||
beforeEnter: requireGuest,
|
||||
},
|
||||
{
|
||||
path: "/signup",
|
||||
name: "signup",
|
||||
component: SignupPage,
|
||||
beforeEnter: requireGuest,
|
||||
},
|
||||
{
|
||||
path: "/check-email",
|
||||
name: "check-email",
|
||||
component: CheckEmailPage,
|
||||
},
|
||||
{
|
||||
path: "/confirm-email",
|
||||
name: "confirm-email",
|
||||
component: ConfirmEmailPage,
|
||||
beforeEnter: (to) => {
|
||||
if (!to.query.token) return { name: "login" };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/users/:displayName",
|
||||
name: "profile",
|
||||
component: ProfilePage,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/users/:displayName/posts/new",
|
||||
name: "new-post",
|
||||
component: NewPostPage,
|
||||
props: true,
|
||||
beforeEnter: requireAuth,
|
||||
},
|
||||
{
|
||||
path: "/posts/:id",
|
||||
name: "post",
|
||||
component: PostPage,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async () => {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.checked) {
|
||||
await auth.fetchMe();
|
||||
}
|
||||
auth.clearError();
|
||||
});
|
||||
|
||||
async function requireGuest() {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.checked) await auth.fetchMe();
|
||||
if (auth.user) return { name: "home" };
|
||||
}
|
||||
|
||||
async function requireAuth() {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.checked) await auth.fetchMe();
|
||||
if (!auth.user) return { name: "login" };
|
||||
}
|
||||
|
||||
export default router;
|
||||
37
frontend/blog_portal/src/stores/auth.ts
Normal file
37
frontend/blog_portal/src/stores/auth.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const checked = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function fetchMe(): Promise<boolean> {
|
||||
checked.value = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
function clearError(): void {
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
checked,
|
||||
error,
|
||||
fetchMe,
|
||||
logout,
|
||||
clearError,
|
||||
};
|
||||
});
|
||||
7
frontend/blog_portal/src/views/CheckEmailPage.vue
Normal file
7
frontend/blog_portal/src/views/CheckEmailPage.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
// CheckEmailPage - filled in by a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section><h1>CheckEmailPage</h1></section>
|
||||
</template>
|
||||
7
frontend/blog_portal/src/views/ConfirmEmailPage.vue
Normal file
7
frontend/blog_portal/src/views/ConfirmEmailPage.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
// ConfirmEmailPage - filled in by a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section><h1>ConfirmEmailPage</h1></section>
|
||||
</template>
|
||||
22
frontend/blog_portal/src/views/HomePage.vue
Normal file
22
frontend/blog_portal/src/views/HomePage.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
// Home page placeholder. Featured posts, recent posts and user
|
||||
// search land here in a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="home">
|
||||
<h1>TIDE</h1>
|
||||
<p class="lead">A blog. Sign up to start posting.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--color-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
7
frontend/blog_portal/src/views/LoginPage.vue
Normal file
7
frontend/blog_portal/src/views/LoginPage.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
// LoginPage - filled in by a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section><h1>LoginPage</h1></section>
|
||||
</template>
|
||||
7
frontend/blog_portal/src/views/NewPostPage.vue
Normal file
7
frontend/blog_portal/src/views/NewPostPage.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
// NewPostPage - filled in by a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section><h1>NewPostPage</h1></section>
|
||||
</template>
|
||||
7
frontend/blog_portal/src/views/PostPage.vue
Normal file
7
frontend/blog_portal/src/views/PostPage.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
// PostPage - filled in by a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section><h1>PostPage</h1></section>
|
||||
</template>
|
||||
7
frontend/blog_portal/src/views/ProfilePage.vue
Normal file
7
frontend/blog_portal/src/views/ProfilePage.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
// ProfilePage - filled in by a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section><h1>ProfilePage</h1></section>
|
||||
</template>
|
||||
7
frontend/blog_portal/src/views/SignupPage.vue
Normal file
7
frontend/blog_portal/src/views/SignupPage.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
// SignupPage - filled in by a later branch.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section><h1>SignupPage</h1></section>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue