feat(rulebook): RulesView three-pane shell + child panes + rule editor
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from "vue";
|
||||||
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
|
|
||||||
|
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const store = useRulebooksStore();
|
||||||
|
const title = ref("");
|
||||||
|
const statement = ref("");
|
||||||
|
const why = ref("");
|
||||||
|
const howToApply = ref("");
|
||||||
|
|
||||||
|
const isCreating = ref(props.ruleId === null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (props.ruleId !== null) {
|
||||||
|
await store.fetchRule(props.ruleId);
|
||||||
|
const r = store.currentRule;
|
||||||
|
if (r) {
|
||||||
|
title.value = r.title;
|
||||||
|
statement.value = r.statement;
|
||||||
|
why.value = r.why || "";
|
||||||
|
howToApply.value = r.how_to_apply || "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title.value = "";
|
||||||
|
statement.value = "";
|
||||||
|
why.value = "";
|
||||||
|
howToApply.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!title.value.trim() || !statement.value.trim()) {
|
||||||
|
emit("close");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCreating.value && props.topicId !== null) {
|
||||||
|
await store.createRule(props.topicId, {
|
||||||
|
title: title.value, statement: statement.value,
|
||||||
|
why: why.value, how_to_apply: howToApply.value,
|
||||||
|
});
|
||||||
|
} else if (props.ruleId !== null) {
|
||||||
|
await store.updateRule(props.ruleId, {
|
||||||
|
title: title.value, statement: statement.value,
|
||||||
|
why: why.value, how_to_apply: howToApply.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (props.ruleId === null) return;
|
||||||
|
if (!confirm("Delete this rule? This cannot be undone.")) return;
|
||||||
|
await store.deleteRule(props.ruleId);
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => props.ruleId, load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="backdrop" @click="save">
|
||||||
|
<aside class="slide-over" @click.stop>
|
||||||
|
<header>
|
||||||
|
<h2>{{ isCreating ? "New rule" : "Edit rule" }}</h2>
|
||||||
|
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
|
||||||
|
<button class="close" @click="save" aria-label="Close">×</button>
|
||||||
|
</header>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<input v-model="title" placeholder="e.g. dev is home" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Statement <span class="required">*</span>
|
||||||
|
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Why
|
||||||
|
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
How to apply
|
||||||
|
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||||||
|
</label>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.slide-over {
|
||||||
|
position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
|
width: min(520px, 90vw);
|
||||||
|
background: var(--color-surface, #18181b);
|
||||||
|
border-left: 2px solid var(--color-primary, #6366f1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
header { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||||
|
header h2 {
|
||||||
|
flex: 1; margin: 0;
|
||||||
|
font-family: Fraunces, serif; font-style: italic;
|
||||||
|
}
|
||||||
|
label { display: block; margin-bottom: 1rem; }
|
||||||
|
.required { color: var(--color-primary, #6366f1); }
|
||||||
|
input, textarea {
|
||||||
|
width: 100%; margin-top: 0.25rem;
|
||||||
|
background: var(--color-bg, #111113); color: inherit;
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
|
padding: 0.5rem; font: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||||
|
.trash:hover, .close:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RuleHeader } from "@/api/rulebooks";
|
||||||
|
|
||||||
|
defineProps<{ topicId: number; rules: RuleHeader[] }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"open-rule": [id: number];
|
||||||
|
"create-rule": [topicId: number];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="pane">
|
||||||
|
<header><h2>Rules</h2></header>
|
||||||
|
<ul>
|
||||||
|
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||||
|
<div class="title">{{ r.title }}</div>
|
||||||
|
<div class="statement">{{ r.statement }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||||
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
li {
|
||||||
|
padding: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 2px solid var(--color-primary, #6366f1);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||||
|
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||||
|
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||||
|
.new-rule { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
|
import type { RulebookTopic } from "@/api/rulebooks";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rulebookId: number;
|
||||||
|
topics: RulebookTopic[];
|
||||||
|
selectedTopicId: number | null;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ "select-topic": [id: number] }>();
|
||||||
|
|
||||||
|
const store = useRulebooksStore();
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const newTitle = ref("");
|
||||||
|
|
||||||
|
async function submitNew() {
|
||||||
|
const title = newTitle.value.trim();
|
||||||
|
if (!title) return;
|
||||||
|
const topic = await store.createTopic(props.rulebookId, { title });
|
||||||
|
newTitle.value = "";
|
||||||
|
isCreating.value = false;
|
||||||
|
emit("select-topic", topic.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="pane">
|
||||||
|
<header><h2>Topics</h2></header>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="t in topics"
|
||||||
|
:key="t.id"
|
||||||
|
:class="{ active: t.id === selectedTopicId }"
|
||||||
|
@click="emit('select-topic', t.id)"
|
||||||
|
>
|
||||||
|
{{ t.title }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="new-topic">
|
||||||
|
<button v-if="!isCreating" @click="isCreating = true">+ New topic</button>
|
||||||
|
<form v-else @submit.prevent="submitNew">
|
||||||
|
<input v-model="newTitle" autofocus placeholder="Topic title (e.g. git-workflow)" />
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
<button type="button" @click="isCreating = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Subscription panel — populated in T14. -->
|
||||||
|
<div class="subscriptions">
|
||||||
|
<h3>Subscribers</h3>
|
||||||
|
<p class="hint">(subscription management — populated in next task)</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||||
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
|
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||||
|
.new-topic input {
|
||||||
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
|
background: var(--color-bg, #111113); color: inherit;
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
|
.subscriptions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.hint { font-size: 0.85em; opacity: 0.6; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
|
import type { Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
|
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
||||||
|
const emit = defineEmits<{ select: [id: number] }>();
|
||||||
|
|
||||||
|
const store = useRulebooksStore();
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const newTitle = ref("");
|
||||||
|
|
||||||
|
async function submitNew() {
|
||||||
|
const title = newTitle.value.trim();
|
||||||
|
if (!title) return;
|
||||||
|
const rb = await store.createRulebook({ title });
|
||||||
|
newTitle.value = "";
|
||||||
|
isCreating.value = false;
|
||||||
|
emit("select", rb.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="pane">
|
||||||
|
<header><h2>Rulebooks</h2></header>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="rb in rulebooks"
|
||||||
|
:key="rb.id"
|
||||||
|
:class="{ active: rb.id === selectedId }"
|
||||||
|
@click="emit('select', rb.id)"
|
||||||
|
>
|
||||||
|
<span class="title">{{ rb.title }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="new-rulebook">
|
||||||
|
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||||
|
<form v-else @submit.prevent="submitNew">
|
||||||
|
<input v-model="newTitle" autofocus placeholder="Rulebook title" />
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
<button type="button" @click="isCreating = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||||
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
|
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||||
|
.new-rulebook { margin-top: 1rem; }
|
||||||
|
.new-rulebook input {
|
||||||
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
|
background: var(--color-bg, #111113); color: inherit;
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
|
import RulebookListPane from "@/components/rules/RulebookListPane.vue";
|
||||||
|
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
||||||
|
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
||||||
|
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
||||||
|
|
||||||
|
const store = useRulebooksStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const selectedRulebookId = ref<number | null>(null);
|
||||||
|
const selectedTopicId = ref<number | null>(null);
|
||||||
|
const editingRuleId = ref<number | null>(null);
|
||||||
|
const creatingRuleForTopic = ref<number | null>(null);
|
||||||
|
|
||||||
|
function syncFromRoute() {
|
||||||
|
const rb = route.query.rb ? Number(route.query.rb) : null;
|
||||||
|
const topic = route.query.topic ? Number(route.query.topic) : null;
|
||||||
|
const rule = route.query.rule ? Number(route.query.rule) : null;
|
||||||
|
selectedRulebookId.value = rb;
|
||||||
|
selectedTopicId.value = topic;
|
||||||
|
editingRuleId.value = rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRulebook(id: number) {
|
||||||
|
selectedRulebookId.value = id;
|
||||||
|
selectedTopicId.value = null;
|
||||||
|
router.replace({ query: { rb: String(id) } });
|
||||||
|
store.fetchTopics(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTopic(id: number) {
|
||||||
|
selectedTopicId.value = id;
|
||||||
|
router.replace({ query: { ...route.query, topic: String(id) } });
|
||||||
|
store.fetchRules(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRule(id: number) {
|
||||||
|
editingRuleId.value = id;
|
||||||
|
router.replace({ query: { ...route.query, rule: String(id) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRuleEditor() {
|
||||||
|
editingRuleId.value = null;
|
||||||
|
creatingRuleForTopic.value = null;
|
||||||
|
const { rule, ...rest } = route.query;
|
||||||
|
void rule;
|
||||||
|
router.replace({ query: rest });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreatingRule(topicId: number) {
|
||||||
|
creatingRuleForTopic.value = topicId;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchRulebooks();
|
||||||
|
syncFromRoute();
|
||||||
|
if (selectedRulebookId.value) await store.fetchTopics(selectedRulebookId.value);
|
||||||
|
if (selectedTopicId.value) await store.fetchRules(selectedTopicId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => route.query, syncFromRoute);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rules-view">
|
||||||
|
<RulebookListPane
|
||||||
|
:rulebooks="store.rulebooks"
|
||||||
|
:selected-id="selectedRulebookId"
|
||||||
|
@select="selectRulebook"
|
||||||
|
/>
|
||||||
|
<RulebookDetailPane
|
||||||
|
v-if="selectedRulebookId !== null"
|
||||||
|
:rulebook-id="selectedRulebookId"
|
||||||
|
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
||||||
|
:selected-topic-id="selectedTopicId"
|
||||||
|
@select-topic="selectTopic"
|
||||||
|
/>
|
||||||
|
<div v-else class="pane empty">
|
||||||
|
<p>Select a rulebook to view its topics.</p>
|
||||||
|
</div>
|
||||||
|
<RuleListPane
|
||||||
|
v-if="selectedTopicId !== null"
|
||||||
|
:topic-id="selectedTopicId"
|
||||||
|
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||||
|
@open-rule="openRule"
|
||||||
|
@create-rule="startCreatingRule"
|
||||||
|
/>
|
||||||
|
<div v-else class="pane empty">
|
||||||
|
<p>Select a topic to view its rules.</p>
|
||||||
|
</div>
|
||||||
|
<RuleEditorSlideOver
|
||||||
|
v-if="editingRuleId !== null || creatingRuleForTopic !== null"
|
||||||
|
:rule-id="editingRuleId"
|
||||||
|
:topic-id="creatingRuleForTopic"
|
||||||
|
@close="closeRuleEditor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rules-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--color-border, #2a2a2e);
|
||||||
|
}
|
||||||
|
.pane.empty {
|
||||||
|
background: var(--color-surface, #18181b);
|
||||||
|
padding: 1rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user