kopia lustrzana https://github.com/learn-awesome/learndb
conflict resolved
commit
c06823a2ec
27
README.md
27
README.md
|
@ -1,21 +1,16 @@
|
||||||
# Learndb
|
# LearnAwesome
|
||||||
|
|
||||||
Non-social version of https://learnawesome.org/
|
An offline-browsable collection of learning resources organized by topics, formats, difficulty level etc.
|
||||||
|
|
||||||
Run `datasette . -o` in the top-level directory.
|
## Users
|
||||||
|
|
||||||
Schema:
|
Run `datasette . -o` in the top-level directory which opens the Datasette default view. Click on "home" in the top-left to open the custom UI which is much nicer.
|
||||||
- Format: inline string like book, course, video, audio, podcast, newsletter, game, toy, website, article etc
|
|
||||||
- Topic (id - using slash or dot separator for hierarchy, display_name, image)
|
|
||||||
- Why not an inline string?
|
|
||||||
- Need to support Special characters (dot, hyphen etc), preserve capitalization etc
|
|
||||||
- Hierarchy may change over time
|
|
||||||
- Item (id, name, description, image, []{format, URL/hash}, rating, topic_id: [], creator_ids: [], year, difficulty, cost, quality_tags, extra_data: {})
|
|
||||||
- Creator (id, name, description, category, social_urls_or_ids, photo)
|
|
||||||
- Review/Recommendation (id, item_id, by: item_id/creator_id, rating, blurb, URL, quality_tags)
|
|
||||||
|
|
||||||
Additional pages:
|
## Developers
|
||||||
- Syllabus page per topic
|
|
||||||
- Format page
|
|
||||||
|
|
||||||
To generate the sqlite database from the source files, run `generatedb.sh`
|
When you modify the *.csv files in `db/`, generate the sqlite database with `./generatedb.sh`.
|
||||||
|
Run `npm run dev` to keep building the JS bundle as you edit the source code.
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
The dataset here is identical to https://learnawesome.org/. But this runs on your computer so there are no user accounts, no social features like learning feeds or ActivityPub. Your bookmarks will be saved in browser's localStorage.
|
|
@ -1,4 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let showSearch = false;
|
||||||
let query = '';
|
let query = '';
|
||||||
let result_items = [];
|
let result_items = [];
|
||||||
let result_topics = [];
|
let result_topics = [];
|
||||||
|
@ -16,20 +20,18 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||||
|
|
||||||
<div class="overflow-y-auto p-4 sm:p-6 md:p-20">
|
{#if showSearch}
|
||||||
<!--
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||||
Command palette, show/hide based on modal state.
|
{/if}
|
||||||
|
|
||||||
Entering: "ease-out duration-300"
|
|
||||||
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
{#if showSearch}
|
||||||
To: "opacity-100 translate-y-0 sm:scale-100"
|
<div class="fixed z-10 inset-0 overflow-y-auto" on:click="{e => dispatch('closed',{}) }">
|
||||||
Leaving: "ease-in duration-200"
|
<div class="flex items-end sm:items-center justify-center min-h-full p-4 text-center sm:p-0">
|
||||||
From: "opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
<div class="mx-auto w-xl transform overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all" on:click|stopPropagation>
|
||||||
-->
|
|
||||||
<div class="mx-auto max-w-xl transform overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- Heroicon name: solid/search -->
|
<!-- Heroicon name: solid/search -->
|
||||||
<svg class="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
@ -87,3 +89,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -18,6 +18,7 @@
|
||||||
let currentView = "/topics";
|
let currentView = "/topics";
|
||||||
let randomItemId;
|
let randomItemId;
|
||||||
let alltopics = [];
|
let alltopics = [];
|
||||||
|
let showSearch = false;
|
||||||
|
|
||||||
function getRandomItemId(){
|
function getRandomItemId(){
|
||||||
fetch('/learn.json?_shape=array&sql=select+rowid+from+items+order+by+random()+limit+1').then(r => r.json())
|
fetch('/learn.json?_shape=array&sql=select+rowid+from+items+order+by+random()+limit+1').then(r => r.json())
|
||||||
|
@ -71,13 +72,13 @@
|
||||||
<ItemDetail itemid={currentView.split("/")[2]}/>
|
<ItemDetail itemid={currentView.split("/")[2]}/>
|
||||||
{:else if currentView == "/random"}
|
{:else if currentView == "/random"}
|
||||||
{#if randomItemId}<ItemDetail itemid={randomItemId}/>{/if}
|
{#if randomItemId}<ItemDetail itemid={randomItemId}/>{/if}
|
||||||
{:else if currentView === "/search"}
|
|
||||||
<AdvancedSearch/>
|
|
||||||
{:else if currentView === "/wanttolearn"}
|
{:else if currentView === "/wanttolearn"}
|
||||||
<ItemList kind={0}/>
|
<ItemList kind={0}/>
|
||||||
{:else if currentView === "/finishedlearning"}
|
{:else if currentView === "/finishedlearning"}
|
||||||
<ItemList kind={1}/>
|
<ItemList kind={1}/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<AdvancedSearch {showSearch} on:closed="{e => showSearch = false}"/>
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
|
|
||||||
<svelte:fragment slot="nav">
|
<svelte:fragment slot="nav">
|
||||||
|
@ -94,10 +95,10 @@
|
||||||
<h3 class="text-center"> Random Item</h3>
|
<h3 class="text-center"> Random Item</h3>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<button on:click="{e => showSearch = true}" class="text-lightSecondary1 w-full hover:bg-lightSecondary1 hover:text-lightSecondary2 hover:dark:text-darkSecondary2 hover:dark:bg-darkPrimaryBg group flex flex-col items-center py-2 text-sm font-medium">
|
||||||
<NavButtonWithLabel isActive={currentView === "/search"} target="#/search" label="Search">
|
|
||||||
<SearchIcon class=" flex-shrink-0 h-6 w-6"/>
|
<SearchIcon class=" flex-shrink-0 h-6 w-6"/>
|
||||||
</NavButtonWithLabel>
|
<h3 class="text-center"> Search</h3>
|
||||||
|
</button>
|
||||||
|
|
||||||
<NavButtonWithLabel isActive={currentView === "/wanttolearn"} target="#/wanttolearn" label="Want to learn">
|
<NavButtonWithLabel isActive={currentView === "/wanttolearn"} target="#/wanttolearn" label="Want to learn">
|
||||||
<BookmarkIcon class=" flex-shrink-0 h-6 w-6"/>
|
<BookmarkIcon class=" flex-shrink-0 h-6 w-6"/>
|
||||||
|
@ -107,7 +108,6 @@
|
||||||
<BookmarkAltIcon class=" flex-shrink-0 h-6 w-6"/>
|
<BookmarkAltIcon class=" flex-shrink-0 h-6 w-6"/>
|
||||||
</NavButtonWithLabel>
|
</NavButtonWithLabel>
|
||||||
|
|
||||||
|
|
||||||
<a href="/learn" class="text-indigo-100 hover:bg-lightSecondary1 hover:text-lightSecondary2 hover:dark:bg-darkPrimaryBg w-full group flex justify-start gap-3 items-center py-5 text-sm font-medium hover:dark:text-darkSecondary2 mb-5 pl-4">
|
<a href="/learn" class="text-indigo-100 hover:bg-lightSecondary1 hover:text-lightSecondary2 hover:dark:bg-darkPrimaryBg w-full group flex justify-start gap-3 items-center py-5 text-sm font-medium hover:dark:text-darkSecondary2 mb-5 pl-4">
|
||||||
<CogIcon class=" flex-shrink-0 h-6 w-6 "/>
|
<CogIcon class=" flex-shrink-0 h-6 w-6 "/>
|
||||||
<h3 class="text-center"> Datasette</h3>
|
<h3 class="text-center"> Datasette</h3>
|
||||||
|
|
|
@ -1,15 +1,43 @@
|
||||||
<script>
|
<script>
|
||||||
import ItemCard from "./ItemCard.svelte"
|
import ItemCard from "./ItemCard.svelte"
|
||||||
|
import SearchForm from "./SearchForm.svelte"
|
||||||
|
|
||||||
export let format;
|
export let format;
|
||||||
export let alltopics;
|
export let alltopics;
|
||||||
let items = [];
|
let items = [];
|
||||||
|
let filteredItems = [];
|
||||||
|
|
||||||
|
let query = {
|
||||||
|
text: "",
|
||||||
|
topic: "",
|
||||||
|
format: "",
|
||||||
|
level: "",
|
||||||
|
quality: "",
|
||||||
|
sortby: "rating"
|
||||||
|
};
|
||||||
|
|
||||||
$: fetch(`/learn/items.json?_shape=array&links__contains=${format}|`)
|
$: fetch(`/learn/items.json?_shape=array&links__contains=${format}|`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
items = data;
|
items = data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleQueryChanged(event){
|
||||||
|
console.log("queryChanged: ", event.detail);
|
||||||
|
query = event.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: filteredItems = items.filter(item => {
|
||||||
|
if(query.text && !item.name.toLowerCase().includes(query.text.toLowerCase())){ return false; }
|
||||||
|
if(query.format && !item.links.includes(query.format)) { return false; }
|
||||||
|
if(query.level && item.difficulty != query.level){ return false; }
|
||||||
|
// TODO Apply quality filter
|
||||||
|
return true;
|
||||||
|
}).sort((a,b) => {
|
||||||
|
if(query.sortby == 'rating') { return (a.rating - b.rating) };
|
||||||
|
if(query.sortby == 'year') { return (a.year - b.year)};
|
||||||
|
if(query.sortby == 'name') { return a.name.localeCompare(b.name)};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="md:flex md:items-center md:justify-between mb-8">
|
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||||
|
@ -18,17 +46,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SearchForm {alltopics} on:queryChanged={handleQueryChanged} hideFormat={true}/>
|
||||||
|
|
||||||
{#if format == 'book'}
|
{#if format == 'book'}
|
||||||
<div class="mt-12 grid gap-5 grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 justify-items-center">
|
<div class="mt-12 grid gap-5 grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 justify-items-center">
|
||||||
{#each items as item}
|
{#each filteredItems as item}
|
||||||
<ItemCard {item} displayType={format}/>
|
<ItemCard {item} displayType={format}/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-12 max-w-lg mx-auto grid gap-5 lg:grid-cols-2 lg:max-w-none xl:grid-cols-3">
|
<div class="mt-12 max-w-lg mx-auto grid gap-5 lg:grid-cols-2 lg:max-w-none xl:grid-cols-3">
|
||||||
{#each items as item}
|
{#each filteredItems as item}
|
||||||
<ItemCard {item} displayType={format}/>
|
<ItemCard {item} displayType={format}/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,12 +3,16 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
export let alltopics;
|
export let alltopics;
|
||||||
|
export let hideFormat = false;
|
||||||
|
export let hideTopic = false;
|
||||||
|
export let hideQuality = true;
|
||||||
|
|
||||||
let query = {
|
let query = {
|
||||||
text: "",
|
text: "",
|
||||||
topic: "",
|
topic: "",
|
||||||
format: "",
|
format: "",
|
||||||
level: "",
|
level: "",
|
||||||
|
quality: "",
|
||||||
sortby: "rating"
|
sortby: "rating"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -23,14 +27,18 @@
|
||||||
<sl-icon name="search" slot="prefix"></sl-icon>
|
<sl-icon name="search" slot="prefix"></sl-icon>
|
||||||
</sl-input>
|
</sl-input>
|
||||||
|
|
||||||
|
{#if !hideTopic}
|
||||||
<ComboBox options={alltopics.map(t => { return {label: t.display_name, value: t.name}; }).sort((a,b) => a.label.localeCompare(b.label))} selected={null}/>
|
<ComboBox options={alltopics.map(t => { return {label: t.display_name, value: t.name}; }).sort((a,b) => a.label.localeCompare(b.label))} selected={null}/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !hideFormat}
|
||||||
<sl-select class="ml-2 w-44" on:sl-change="{e => query.format = e.target.value}" value={query.format}>
|
<sl-select class="ml-2 w-44" on:sl-change="{e => query.format = e.target.value}" value={query.format}>
|
||||||
<sl-menu-item value="">Any format</sl-menu-item>
|
<sl-menu-item value="">Any format</sl-menu-item>
|
||||||
<sl-menu-item value="book">Books</sl-menu-item>
|
<sl-menu-item value="book">Books</sl-menu-item>
|
||||||
<sl-menu-item value="video">Videos</sl-menu-item>
|
<sl-menu-item value="video">Videos</sl-menu-item>
|
||||||
<sl-menu-item value="audio">Podcasts</sl-menu-item>
|
<sl-menu-item value="audio">Podcasts</sl-menu-item>
|
||||||
</sl-select>
|
</sl-select>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<sl-select class="ml-2 w-44" on:sl-change="{e => query.level = e.target.value}" value={query.level}>
|
<sl-select class="ml-2 w-44" on:sl-change="{e => query.level = e.target.value}" value={query.level}>
|
||||||
<sl-menu-item value="">Any level</sl-menu-item>
|
<sl-menu-item value="">Any level</sl-menu-item>
|
||||||
|
@ -41,7 +49,16 @@
|
||||||
<sl-menu-item value="research">Research</sl-menu-item>
|
<sl-menu-item value="research">Research</sl-menu-item>
|
||||||
</sl-select>
|
</sl-select>
|
||||||
|
|
||||||
<sl-select class="ml-2 w-44" on:sl-change="{e => query.sortby = e.target.value}" value={query.sortby}>
|
{#if !hideQuality}
|
||||||
|
<sl-select class="ml-2 w-44" on:sl-change="{e => query.quality = e.target.value}" value={query.quality}>
|
||||||
|
<sl-menu-item value="">Any quality</sl-menu-item>
|
||||||
|
<sl-menu-item value="visual">Visual</sl-menu-item>
|
||||||
|
<sl-menu-item value="interactive">Interactive</sl-menu-item>
|
||||||
|
<sl-menu-item value="entertaining">Entertaining</sl-menu-item>
|
||||||
|
</sl-select>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<sl-select class="ml-2 w-52" on:sl-change="{e => query.sortby = e.target.value}" value={query.sortby}>
|
||||||
<sl-icon name="sort-down-alt" slot="prefix"></sl-icon>
|
<sl-icon name="sort-down-alt" slot="prefix"></sl-icon>
|
||||||
<sl-menu-item value="rating">Sort by Rating</sl-menu-item>
|
<sl-menu-item value="rating">Sort by Rating</sl-menu-item>
|
||||||
<sl-menu-item value="year">Sort by Year</sl-menu-item>
|
<sl-menu-item value="year">Sort by Year</sl-menu-item>
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
topic: "",
|
topic: "",
|
||||||
format: "",
|
format: "",
|
||||||
level: "",
|
level: "",
|
||||||
|
quality: "",
|
||||||
sortby: "rating"
|
sortby: "rating"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -37,6 +38,7 @@
|
||||||
if(query.text && !item.name.toLowerCase().includes(query.text.toLowerCase())){ return false; }
|
if(query.text && !item.name.toLowerCase().includes(query.text.toLowerCase())){ return false; }
|
||||||
if(query.format && !item.links.includes(query.format)) { return false; }
|
if(query.format && !item.links.includes(query.format)) { return false; }
|
||||||
if(query.level && item.difficulty != query.level){ return false; }
|
if(query.level && item.difficulty != query.level){ return false; }
|
||||||
|
// TODO: apply quality filter
|
||||||
return true;
|
return true;
|
||||||
}).sort((a,b) => {
|
}).sort((a,b) => {
|
||||||
if(query.sortby == 'rating') { return (a.rating - b.rating) };
|
if(query.sortby == 'rating') { return (a.rating - b.rating) };
|
||||||
|
@ -50,23 +52,17 @@
|
||||||
|
|
||||||
<TopicMasonryGrid {topicname} {alltopics}/>
|
<TopicMasonryGrid {topicname} {alltopics}/>
|
||||||
|
|
||||||
<SearchForm {alltopics} on:queryChanged={handleQueryChanged}/>
|
<SearchForm {alltopics} on:queryChanged={handleQueryChanged} hideTopic={true} hideFormat={true}/>
|
||||||
|
|
||||||
<!-- <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{#each items as item}
|
|
||||||
<ItemCard {item}/>
|
|
||||||
{/each}
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<div class="">
|
<div class="">
|
||||||
<sl-tab-group placement="start">
|
<sl-tab-group placement="start">
|
||||||
{#each formats.filter(f => items.filter(x => x.links.includes(f.id + '|')).length > 0) as format}
|
{#each formats.filter(f => items.filter(x => x.links.includes(f.id + '|')).length > 0) as format, i}
|
||||||
<sl-tab slot="nav" panel="{format.id}" class="">{format.name}</sl-tab>
|
<sl-tab slot="nav" panel={format.id} active={i == 0}>{format.name}</sl-tab>
|
||||||
|
|
||||||
|
|
||||||
{#if format.id == 'book'}
|
{#if format.id == 'book'}
|
||||||
<sl-tab-panel name="{format.id}">
|
<sl-tab-panel name={format.id} active={i == 0}>
|
||||||
<div class="grid gap-5 grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 justify-items-center">
|
<div class="grid gap-5 grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 justify-items-center">
|
||||||
{#each filteredItems.filter(x => x.links.includes(format.id + '|')) as item}
|
{#each filteredItems.filter(x => x.links.includes(format.id + '|')) as item}
|
||||||
<BookCard {item}/>
|
<BookCard {item}/>
|
||||||
|
@ -75,7 +71,7 @@
|
||||||
</sl-tab-panel>
|
</sl-tab-panel>
|
||||||
|
|
||||||
{:else if format.id == 'video'}
|
{:else if format.id == 'video'}
|
||||||
<sl-tab-panel name="{format.id}">
|
<sl-tab-panel name={format.id} active={i == 0}>
|
||||||
<div class="max-w-lg mx-auto grid gap-5 lg:grid-cols-2 lg:max-w-none xl:grid-cols-3">
|
<div class="max-w-lg mx-auto grid gap-5 lg:grid-cols-2 lg:max-w-none xl:grid-cols-3">
|
||||||
{#each filteredItems.filter(x => x.links.includes(format.id + '|')) as item}
|
{#each filteredItems.filter(x => x.links.includes(format.id + '|')) as item}
|
||||||
<VideoCard {item}/>
|
<VideoCard {item}/>
|
||||||
|
@ -84,7 +80,7 @@
|
||||||
</sl-tab-panel>
|
</sl-tab-panel>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<sl-tab-panel name="{format.id}">
|
<sl-tab-panel name={format.id} active={i == 0}>
|
||||||
<div class="max-w-lg mx-auto grid gap-5 lg:grid-cols-2 lg:max-w-none xl:grid-cols-4 2xl:grid-cols-6 ">
|
<div class="max-w-lg mx-auto grid gap-5 lg:grid-cols-2 lg:max-w-none xl:grid-cols-4 2xl:grid-cols-6 ">
|
||||||
{#each filteredItems.filter(x => x.links.includes(format.id + '|')) as item}
|
{#each filteredItems.filter(x => x.links.includes(format.id + '|')) as item}
|
||||||
<GenericCard {item}/>
|
<GenericCard {item}/>
|
||||||
|
|
Ładowanie…
Reference in New Issue