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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ Xdebug is pre-configured in the Sail Docker environment for local debugging.

## Deployment

We use Ansible with a `deploy.yaml` script, along with the inventory `production.ini`

`ansible-playbook -i ./ansible/inventory/production.ini deploy.yml`

## License
Expand Down
87 changes: 61 additions & 26 deletions resources/js/Components/Form/TagSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from "vue"
import { defineModel } from "vue";
import axios from "axios";
import { Icon } from "@iconify/vue";
import Tag from "@/Components/Tag.vue";

const DEBOUNCE_TIME = 450; // milliseconds

Expand All @@ -13,6 +14,15 @@ const props = defineProps({
type: String,
required: true,
},
mode: {
type: String,
default: 'create', // 'create' or 'search'
validator: (value) => ['create', 'search'].includes(value),
},
allowEverything: {
type: Boolean,
default: true,
},
});

const model = defineModel();
Expand Down Expand Up @@ -90,6 +100,9 @@ const availableTags = computed(() => {
});

const canCreateNew = computed(() => {
// In search mode, cannot create new tags
if (props.mode === 'search') return false;

const query = searchQuery.value.trim();
if (!query || isLoading.value) return false;

Expand All @@ -102,6 +115,20 @@ const canCreateNew = computed(() => {
);
});

const tooltipText = computed(() => {
const parts = [];

if (props.mode === 'create') {
parts.push("Add multiple tags using commas");
}

if (props.allowEverything) {
parts.push("'everything' tag covers all possible tags");
}

return parts.join('. ');
});

// sync model
watch(
() => model.value,
Expand Down Expand Up @@ -176,7 +203,8 @@ function onKeydown(event) {
canCreateNew.value
) {
selectTag(searchQuery.value.trim());
} else if (searchQuery.value.trim()) {
} else if (props.mode === 'create' && searchQuery.value.trim()) {
// Only allow adding tags in create mode
// Handle comma-separated tags
if (searchQuery.value.includes(',')) {
addMultipleTags(searchQuery.value);
Expand Down Expand Up @@ -258,20 +286,15 @@ onMounted(async () => {
<div class="mb-2">
<!-- list of selected tags -->
<div class="mb-2 flex flex-wrap" v-if="selectedTags.length > 0">
<span
<Tag
v-for="tag in selectedTags"
:key="tag"
class="inline-flex items-center mr-2 my-1 bg-secondary text-primaryDark dark:bg-gray-700 dark:text-white px-3 py-1 rounded-full text-sm font-medium transition-colors"
>
<button
@click="removeTag(tag)"
class="mr-2 text-primaryDark dark:text-white"
type="button"
>
<Icon :icon="'mdi:close'" />
</button>
<span>{{ tag }}</span>
</span>
:tag="tag"
variant="selected"
removable
@remove="removeTag"
class="mr-2 my-1"
/>
</div>

<!-- search input container -->
Expand All @@ -289,13 +312,14 @@ onMounted(async () => {
:class="{
'rounded-b-none border-b-0':
showDropdown &&
(availableTags.length > 0 || canCreateNew || isLoading),
(availableTags.length > 0 || canCreateNew || isLoading || searchQuery.trim()),
}"
/>
<Icon
v-if="tooltipText"
icon="mdi:information-outline"
class="absolute right-3 size-5 text-gray-400 dark:text-gray-500 cursor-help"
v-tooltip.top="'You can add multiple tags at once by separating them with commas (e.g., tag1, tag2, tag3)'"
v-tooltip.top="tooltipText"
/>
</div>

Expand All @@ -304,7 +328,7 @@ onMounted(async () => {
<div
v-show="
showDropdown &&
(availableTags.length > 0 || canCreateNew || isLoading)
(availableTags.length > 0 || canCreateNew || isLoading || searchQuery.trim())
"
class="z-[9999] bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-800 border-t-0 rounded-b-lg shadow-lg max-h-60 overflow-y-auto"
:style="dropdownStyles"
Expand All @@ -325,6 +349,20 @@ onMounted(async () => {

<!-- available tags -->
<template v-else>
<!-- No results message -->
<div
v-if="availableTags.length === 0 && !canCreateNew"
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center"
>
<Icon icon="mdi:information-outline" class="inline-block w-4 h-4 mr-1" />
<span v-if="searchQuery.trim()">
No tags found matching "{{ searchQuery.trim() }}"
</span>
<span v-else-if="props.mode === 'search'">
No tags available
</span>
</div>

<div
v-for="(tag, index) in availableTags"
:key="tag.name"
Expand All @@ -334,18 +372,15 @@ onMounted(async () => {
:class="{
'bg-secondary text-primaryDark dark:bg-gray-800 dark:text-primaryLight': highlightedIndex === index,
'hover:bg-gray-50 dark:hover:bg-gray-900': highlightedIndex !== index,
'bg-orange-50 dark:bg-orange-900/20 border-l-2 border-orange-400': tag.name === 'everything' && highlightedIndex !== index,
'bg-orange-100 dark:bg-orange-900/30 border-l-2 border-orange-500': tag.name === 'everything' && highlightedIndex === index,
}"
>
<span class="text-sm">{{ tag.name }}</span>
<span
v-if="tag.count"
class="text-xs bg-gray-100 text-gray-600 dark:bg-gray-900 dark:text-gray-300 px-2 py-1 rounded-full"
:class="{
'bg-secondary text-primaryDark dark:bg-gray-800 dark:text-primaryLight': highlightedIndex === index,
}"
>
{{ tag.count }}
</span>
<Tag
:tag="tag.name"
:variant="highlightedIndex === index ? 'highlighted' : 'default'"
:count="tag.count || null"
/>
</div>

<!-- create new tag option -->
Expand Down
3 changes: 3 additions & 0 deletions resources/js/Components/Resources/FilterBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ function resetFilters() {
</label>
<TagSelector
:tag-type="'topics_tags'"
:mode="'search'"
v-model="selectedTopics"
class="w-full"
/>
Expand All @@ -334,6 +335,7 @@ function resetFilters() {
</label>
<TagSelector
:tag-type="'programming_languages_tags'"
:mode="'search'"
v-model="selectedProgrammingLanguages"
class="w-full"
/>
Expand All @@ -347,6 +349,7 @@ function resetFilters() {
</label>
<TagSelector
:tag-type="'general_tags'"
:mode="'search'"
v-model="selectedGeneralTags"
class="w-full"
/>
Expand Down
63 changes: 20 additions & 43 deletions resources/js/Components/Resources/ResourceCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { platformIcons, pricingIcons, difficultyIcons } from "@/Helpers/icons";
import ResourceThumbnail from "@/Components/Resources/ResourceThumbnail.vue";
import StarRating from "@/Components/StarRating/StarRating.vue";
import Tag from "@/Components/Tag.vue";

const props = defineProps({
resource: {
Expand Down Expand Up @@ -90,55 +91,38 @@ const emit = defineEmits(["upvote", "downvote"]);
<div class="flex items-center gap-3 flex-wrap">
<!-- Top topics -->
<div class="flex items-center gap-1">
<span
<Tag
v-for="topic in resource.topics_tags?.slice(
0,
4
)"
:key="topic"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-100 bg-transparent" >
<Icon
icon="mdi:lightbulb-outline"
width="12"
height="12"
class="mr-1"
/>
{{ topic }}
</span>
:tag="topic"
icon="mdi:lightbulb-outline"
:icon-size="12"
/>
</div>

<!-- Difficulty -->
<div
v-if="resource.difficulties?.length"
class="flex items-center gap-1"
>
<span
<Tag
v-for="difficulty in resource.difficulties"
:key="difficulty"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-transparent text-primaryDark border border-primary/20"
>
<Icon
:icon="difficultyIcons[difficulty]"
width="12"
height="12"
class="mr-1"
/>
{{ difficultyLabels[difficulty] }}
</span>
:tag="difficultyLabels[difficulty]"
:icon="difficultyIcons[difficulty]"
:icon-size="12"
/>
</div>

<!-- Pricing -->
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-transparent text-primaryDark border border-primary/20"
>
<Icon
:icon="pricingIcons[resource.pricing]"
width="12"
height="12"
class="mr-1"
/>
{{ pricingLabels[resource.pricing] }}
</span>
<Tag
:tag="pricingLabels[resource.pricing]"
:icon="pricingIcons[resource.pricing]"
:icon-size="12"
/>
</div>
</div>

Expand All @@ -162,19 +146,12 @@ const emit = defineEmits(["upvote", "downvote"]);

<!-- Secondary info row -->
<div class="flex items-center gap-1">
<span
<Tag
v-for="platform in resource.platforms.slice(0, 2)"
:key="platform"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-100 bg-transparent"
>
<Icon
:icon="platformIcons[platform]"
width="10"
height="10"
class="mr-1"
/>
{{ platformLabels[platform] }}
</span>
:tag="platformLabels[platform]"
:icon="platformIcons[platform]"
/>
<!-- Rest as comma-separated text -->
<span
v-if="
Expand Down
Loading