Skip to content

Commit 5eec209

Browse files
committed
Merge branch 'master' of github.com:chamilo/chamilo-lms
2 parents fa3ffda + 1e7f196 commit 5eec209

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+4958
-138
lines changed

assets/css/scss/_blog.scss

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
.blog-admin {
2+
.field { margin-bottom: 0 !important;}
3+
.admin-actions{
4+
display: grid;
5+
grid-template-columns: minmax(260px, 34vw) auto;
6+
align-items: center;
7+
gap: .75rem;
8+
}
9+
.search-input :deep(input){ height: 40px; line-height: 40px; }
10+
.controls{
11+
display: flex; align-items: center; gap: .75rem; margin-top: 1rem;
12+
}
13+
.cards-grid{
14+
display: grid; gap: 1rem; margin-top: 1rem;
15+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
16+
}
17+
.card{
18+
border-radius: 16px;
19+
border: 1px solid var(--surface-border,#e5e7eb);
20+
background: var(--surface-card,#fff);
21+
box-shadow: 0 6px 22px rgba(2,6,23,.05);
22+
padding: 1rem;
23+
display: flex; flex-direction: column; gap: .6rem;
24+
}
25+
.card-head{ display:flex; justify-content:space-between; gap:.75rem; }
26+
.meta{ font-size:.8rem; color:#6b7280; }
27+
.title{ margin:0; font-size:1rem; font-weight:600; line-height:1.25; }
28+
.subtitle{ font-size:.82rem; color:#6b7280; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
29+
.badge{
30+
display:inline-flex; align-items:center; gap:.35rem;
31+
padding:.35rem .65rem; border-radius:999px; font-size:.75rem;
32+
border:1px solid transparent;
33+
}
34+
.badge--ok{ background:#ecfdf5; color:#065f46; border-color:#a7f3d0; }
35+
.badge--muted{ background:#eef2f7; color:#334155; border-color:#d9e1ea; }
36+
.owner{ display:flex; align-items:center; gap:.4rem; color:#4b5563; font-size:.85rem; }
37+
.actions.icons{
38+
display:flex; justify-content:flex-end; gap:.45rem; margin-top:.35rem;
39+
}
40+
.footer{ display:flex; justify-content:flex-end; align-items:center; gap:.5rem; margin-top:1.5rem; padding:1rem;}
41+
.admin-actions { display:flex; gap:.5rem; align-items:center; }
42+
.search-input { width:16rem; }
43+
.controls { display:flex; gap:1rem; align-items:center; padding:1rem; }
44+
45+
.cards-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(260px,1fr)); gap:1rem; padding:1rem; }
46+
.card { border:1px solid #e5e7eb; border-radius:.75rem; background:#fff; padding:1rem;
47+
display:flex; flex-direction:column; gap:.5rem; transition: opacity .2s ease, filter .2s ease; }
48+
/* visually mute hidden projects */
49+
.card--hidden { opacity:.6; filter:grayscale(1); }
50+
51+
.card-head { display:flex; justify-content:space-between; gap:1rem; }
52+
.meta { font-size:.75rem; color:#6b7280; }
53+
54+
/* clickable title */
55+
.title { margin:.25rem 0; font-weight:600; }
56+
.title-link { color:inherit; text-decoration:none; }
57+
.title-link:hover .title { text-decoration:underline; }
58+
59+
.subtitle { color:#6b7280; font-size:.9rem; }
60+
.owner { display:flex; align-items:center; gap:.5rem; color:#374151; font-size:.9rem; }
61+
62+
.actions.icons { display:flex; gap:.25rem; justify-content:flex-end; }
63+
.icon-btn { --tw-ring-color: transparent; }
64+
@media (max-width: 720px){
65+
.admin-actions{ grid-template-columns: 1fr; }
66+
}
67+
}
68+
69+
.blog-posts {
70+
.field { margin-bottom: 0 !important; }
71+
.segmented{
72+
display:inline-flex; background:#f3f4f6; border-radius:999px; padding:.125rem; border:1px solid #e5e7eb;
73+
}
74+
.seg-btn{
75+
appearance:none; border:0; background:transparent; padding:.25rem .75rem;
76+
border-radius:999px; font-size:.875rem; color:#374151; cursor:pointer;
77+
}
78+
.seg-btn.active{ background:#fff; box-shadow:0 1px 2px rgba(0,0,0,.06); color:#111827; }
79+
.segmented {
80+
display: inline-flex;
81+
border: 1px solid #e5e7eb;
82+
border-radius: .5rem;
83+
overflow: hidden;
84+
}
85+
.seg-btn {
86+
padding: .35rem .6rem;
87+
font-size: .875rem;
88+
background: #fff;
89+
}
90+
.seg-btn + .seg-btn { border-left: 1px solid #e5e7eb; }
91+
.seg-btn.active {
92+
background: #eef2ff;
93+
color: #4338ca;
94+
font-weight: 600;
95+
}
96+
.bg-gray-10 { background: #f4f5f7; }
97+
.bg-gray-20 { background: #f9fafb; }
98+
.blog-posts :is(h2, h3) { line-height: 1.2; }
99+
}
100+
101+
.blog-layout {
102+
.nav-link {
103+
padding: 0.25rem 0.75rem;
104+
border-radius: 0.5rem;
105+
color: var(--text-color, #334155);
106+
text-decoration: none;
107+
font-weight: 500;
108+
}
109+
.nav-link:hover {
110+
background: color-mix(in srgb, var(--primary-color, #2563eb) 10%, transparent);
111+
color: var(--primary-color, #2563eb);
112+
}
113+
a.router-link-active.nav-link {
114+
background: color-mix(in srgb, var(--primary-color, #2563eb) 15%, transparent);
115+
color: var(--primary-color, #2563eb);
116+
}
117+
.nav-link {
118+
@apply px-3 py-1 rounded text-sm text-gray-90 hover:bg-gray-20 no-underline;
119+
}
120+
.nav-link.active {
121+
@apply font-medium;
122+
}
123+
.calendar{ user-select:none }
124+
.cal-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:.25rem }
125+
.month{ font-weight:600; font-size:.85rem }
126+
.nav{ background:#f3f4f6; border:1px solid #e5e7eb; border-radius:8px; width:28px; height:28px; display:grid; place-items:center }
127+
.nav:hover{ background:#e5e7eb }
128+
.day{
129+
background:#fff; border:1px solid #e5e7eb; border-radius:8px; height:32px;
130+
font-size:.85rem; display:grid; place-items:center; cursor:pointer;
131+
}
132+
.day:hover{ background:#f9fafb }
133+
.day.selected{ background:#2563eb; color:#fff; border-color:#2563eb }
134+
}

assets/css/scss/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,6 @@
101101
@include meta.load-css('skill');
102102
@include meta.load-css('survey');
103103
@include meta.load-css('chat');
104+
@include meta.load-css('blog');
104105

105106
@include meta.load-css("libs/mediaelementjs/styles");
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<template>
2+
<BaseDialog v-model:isVisible="visible" :title="t('Assign Task')" header-icon="account-plus" :width="'560px'">
3+
<div class="space-y-3">
4+
<BaseSelect v-model="taskId" :options="tasks" optionLabel="title" optionValue="id" :placeholder="t('Select a task')" label="" />
5+
<BaseSelect v-model="userId" :options="members" optionLabel="name" optionValue="id" :placeholder="t('Select a user')" label="" />
6+
<div>
7+
<label class="text-sm block mb-1">{{ t("Target date") }}</label>
8+
<input type="date" v-model="date" class="border rounded px-2 py-1" />
9+
</div>
10+
<div class="flex justify-end gap-2">
11+
<BaseButton type="black" icon="close" :label="t('Cancel')" @click="close" />
12+
<BaseButton type="primary" icon="check" :label="t('Assign')" :disabled="!canSubmit" :isLoading="saving" @click="submit" />
13+
</div>
14+
</div>
15+
</BaseDialog>
16+
</template>
17+
18+
<script setup>
19+
import { computed, ref } from "vue"
20+
import { useI18n } from "vue-i18n"
21+
22+
import service from "../../services/blogs"
23+
import BaseButton from "../basecomponents/BaseButton.vue"
24+
import BaseSelect from "../basecomponents/BaseSelect.vue"
25+
import BaseDialog from "../basecomponents/BaseDialog.vue"
26+
27+
const { t } = useI18n()
28+
const props = defineProps({
29+
blogId: { type: Number, required: true },
30+
tasks: { type: Array, default: () => [] },
31+
members: { type: Array, default: () => [] },
32+
})
33+
const emit = defineEmits(["close","assigned"])
34+
const visible = ref(true)
35+
const taskId = ref(null)
36+
const userId = ref(null)
37+
const date = ref(new Date().toISOString().slice(0,10))
38+
const saving = ref(false)
39+
const canSubmit = computed(() => !!taskId.value && !!userId.value && !!date.value)
40+
41+
function close(){ visible.value=false; emit("close") }
42+
async function submit(){
43+
if (!canSubmit.value) return
44+
saving.value = true
45+
try {
46+
await service.assignTask({
47+
blogId: props.blogId,
48+
taskId: taskId.value,
49+
userId: userId.value,
50+
targetDate: date.value,
51+
})
52+
emit("assigned")
53+
close()
54+
} finally { saving.value=false }
55+
}
56+
</script>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<template>
2+
<div class="dbx w-full min-h-full flex flex-col bg-[var(--surface-ground,#fff)] blog-layout">
3+
<BaseToolbar class="sticky top-0 z-20 bg-[var(--surface-card,#fff)]">
4+
<template #start>
5+
<div class="flex items-center gap-3">
6+
<i class="mdi mdi-notebook-outline text-2xl text-primary"></i>
7+
<div>
8+
<h2 class="m-0 text-lg font-semibold">
9+
{{ blog?.title || t("Blogs") }}
10+
</h2>
11+
<div v-if="blog?.subtitle" class="text-xs text-gray-500">
12+
{{ blog.subtitle }}
13+
</div>
14+
</div>
15+
</div>
16+
</template>
17+
18+
<template #end>
19+
<!-- Primary nav -->
20+
<RouterLink
21+
class="nav-link"
22+
:class="{ active: $route.name === 'BlogPosts' }"
23+
:to="{ name:'BlogPosts', params:$route.params, query:$route.query }"
24+
>
25+
{{ t("Posts") }}
26+
</RouterLink>
27+
28+
<RouterLink
29+
class="nav-link"
30+
:class="{ active: $route.name === 'BlogTasks' }"
31+
:to="{ name:'BlogTasks', params:$route.params, query:$route.query }"
32+
>
33+
{{ t("Tasks") }}
34+
</RouterLink>
35+
36+
<RouterLink
37+
class="nav-link"
38+
:class="{ active: $route.name === 'BlogMembers' }"
39+
:to="{ name:'BlogMembers', params:$route.params, query:$route.query }"
40+
>
41+
{{ t("Members") }}
42+
</RouterLink>
43+
44+
<!-- Visible to course admins/teachers only -->
45+
<RouterLink
46+
v-if="isAdminOrTeacher"
47+
class="nav-link"
48+
:class="{ active: $route.name === 'BlogsAdmin' }"
49+
:to="{
50+
name: 'BlogsAdmin',
51+
params: { ...$route.params, node: $route.params.node ?? 'course' },
52+
query: $route.query
53+
}"
54+
>
55+
{{ t("Projects") }}
56+
</RouterLink>
57+
</template>
58+
</BaseToolbar>
59+
60+
<section class="p-4 md:p-6">
61+
<RouterView />
62+
</section>
63+
</div>
64+
</template>
65+
66+
<script setup>
67+
import { onMounted, watch, ref, computed } from "vue"
68+
import { useI18n } from "vue-i18n"
69+
import { useRoute } from "vue-router"
70+
import BaseToolbar from "../basecomponents/BaseToolbar.vue"
71+
import service from "../../services/blogs"
72+
import { useSecurityStore } from "../../store/securityStore"
73+
74+
const { t } = useI18n()
75+
const route = useRoute()
76+
77+
// Access control (admin/teacher only)
78+
const securityStore = useSecurityStore()
79+
const isAdminOrTeacher = computed(() => securityStore.isAdmin || securityStore.isTeacher)
80+
81+
// Blog meta (title/subtitle)
82+
const blog = ref(null)
83+
84+
async function loadBlogMeta() {
85+
try {
86+
const id = Number(route.params.blogId)
87+
if (!id) {
88+
blog.value = null
89+
return
90+
}
91+
blog.value = await service.getProject(id)
92+
} catch (e) {
93+
// eslint-disable-next-line no-console
94+
console.warn("BlogLayout: failed to fetch blog meta", e)
95+
blog.value = null
96+
}
97+
}
98+
99+
onMounted(loadBlogMeta)
100+
watch(() => route.params.blogId, loadBlogMeta)
101+
</script>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<template>
2+
<div class="calendar">
3+
<div class="cal-head">
4+
<button class="nav" @click="$emit('prev')" :title="t('Previous')">
5+
<i class="mdi mdi-chevron-left"></i>
6+
</button>
7+
<div class="month">{{ monthLabel }} {{ year }}</div>
8+
<button class="nav" @click="$emit('next')" :title="t('Next')">
9+
<i class="mdi mdi-chevron-right"></i>
10+
</button>
11+
</div>
12+
13+
<div class="grid grid-cols-7 text-[11px] text-gray-500 mb-1">
14+
<div v-for="d in weekDays" :key="d" class="text-center py-1">{{ d }}</div>
15+
</div>
16+
17+
<div class="grid grid-cols-7 gap-1">
18+
<div v-for="i in startOffset" :key="'off'+i"></div>
19+
<button
20+
v-for="d in daysInMonth"
21+
:key="d"
22+
class="day"
23+
:class="isSelected(d) ? 'selected' : ''"
24+
@click="select(d)"
25+
>
26+
{{ d }}
27+
</button>
28+
</div>
29+
</div>
30+
</template>
31+
32+
<script setup>
33+
import { computed } from "vue"
34+
import { useI18n } from "vue-i18n"
35+
36+
const props = defineProps({
37+
year: { type: Number, required: true },
38+
month: { type: Number, required: true }, // 1-12
39+
selected: { type: String, default: "" }, // YYYY-MM-DD
40+
})
41+
const emit = defineEmits(["select","prev","next"])
42+
const { t } = useI18n()
43+
44+
const monthLabel = computed(() =>
45+
new Date(props.year, props.month - 1, 1).toLocaleString(undefined, { month: "long" })
46+
)
47+
const weekDays = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]
48+
49+
const firstWeekday = computed(() => {
50+
// Convert Sunday=0 -> 7
51+
let w = new Date(props.year, props.month - 1, 1).getDay()
52+
if (w === 0) w = 7
53+
return w // 1..7 (Mon..Sun)
54+
})
55+
const startOffset = computed(() => firstWeekday.value - 1)
56+
const daysInMonth = computed(() => new Date(props.year, props.month, 0).getDate())
57+
58+
function pad(n){ return String(n).padStart(2,"0") }
59+
function iso(d){ return `${props.year}-${pad(props.month)}-${pad(d)}` }
60+
function isSelected(d){ return props.selected === iso(d) }
61+
function select(d){ emit("select", iso(d)) }
62+
</script>

0 commit comments

Comments
 (0)